WebGL Essentials

Vertex Buffer Objects

Vertex data can be placed into arrays stored in the system RAM or in the high-performance GPU memory. With client-side arrays, OpenGL has to copy all the vertex values to the command buffer, then the data is passed to the graphics hardware. Obviously, performance costs will be increasing rapidly when the application starts processing large data sets.

Client arrays are not supported by WebGL: a vertex buffer object residing in the GPU address space must be used instead. This article will illustrate the way of employing the WebGL API to write coordinate and color values to the buffer and retrieve information about vertex attributes. For demonstration, each stage is represented as a separate function: first a WebGL context is initialized, then shaders are compiled and attached to a program; the next step is creation of a vertex buffer; finally, the canvas is cleared, and a triangle with interpolated colors is drawn.

Shaders

Our demo application will use the modified shaders from the previous article:

<script id="vertex-shader" type="x-shader/x-vertex">
 attribute vec3 position;
 attribute vec3 color;
 varying highp vec3 colour;
 void main() {
  gl_Position = vec4(position, 1.0);
  colour = color;
 }
</script>

<script id="fragment-shader" type="x-shader/x-fragment">
 varying highp vec3 colour;
 void main() {
  gl_FragColor = vec4(colour, 1.0);
 }
</script>

What has changed? First of all, we see two attribute variables - position and color. The position provides linkage between the vertex shader and per-vertex coordinates that will be specified in JavaScript. The color will receive an array of RGB values for each vertex from the client code, too. Another new trait is the varying storage qualifier for the colour variable (hello from British English!). The qualifier means that the variable acts as the interface between shaders: the vertex shader processes color components and pass them to the colour, then the fragment shader reads the data and apply it to fragments. The highp is a precision qualifier.

WebGL Context Initialization

The demo code employs five global variables:

var gl; // WebGL context
var vs, fs, program; // shaders and program
var vbo; // vertex buffer object

The first variable is used immediately in the createWebGLContext() function to initialize a valid 3D context:

function createWebGLContext() {
 var canvas = document.querySelector('canvas');
 gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
 if(gl == null) {
  console.error('WebGL is not supported by the browser');
 } else {
  compileVertexShader();
 }
}

The createWebGLContext() can be defined as a handler of the load event of the document body. The experimental-webgl argument allows us to obtain the 3D context in older browsers.

Shaders Compilation

If the context is successfully initialized, the application compiles the shaders. For the vertex shader the code might look like this:

function compileVertexShader() {
 var vsSource = document.querySelector('#vertex-shader').textContent;
 vs = gl.createShader(gl.VERTEX_SHADER);
 gl.shaderSource(vs, vsSource);
 gl.compileShader(vs);
 if(gl.getShaderParameter(vs, gl.COMPILE_STATUS) == true) {
  console.info('Vertex shader has been compiled successfully');
  compileFragmentShader();
 } else {
  console.log('%cVertex shader compilation failed:\n%s', 'color: red' , gl.getShaderInfoLog(vs));
 }
}

The same operations for the fragment shader can be represented as

function compileFragmentShader() {
 var fsSource = document.querySelector('#fragment-shader').textContent;
 fs = gl.createShader(gl.FRAGMENT_SHADER);
 gl.shaderSource(fs, fsSource);
 gl.compileShader(fs);
 if(gl.getShaderParameter(fs, gl.COMPILE_STATUS) == true) {
  console.info('Fragment shader has been compiled successfully');
  createProgram();
 } else {
  console.log('%cFragment shader compilation failed:\n%s', 'color: red' , gl.getShaderInfoLog(fs));
 }
}

The next step is the program linking:

function createProgram() {
 program = gl.createProgram();
 gl.attachShader(program, vs);
 gl.attachShader(program, fs);
 gl.linkProgram(program);
 if(gl.getProgramParameter(program, gl.LINK_STATUS) == true) {
  console.info('A valid program executable is created');
  gl.useProgram(program);
  createVertexBufferObject();
 } else {
  console.log('%cProgram linking failed:\n%s', 'color: red' , gl.getProgramInfoLog(program));
 }
}

WebGLBuffer Object

The triangle coordinates will be defined in the normalized device space: x, y and z vary from -1.0 to 1.0. The center of the 3D coordinate system is the point at (0, 0, 0), so the positive x axis runs to the right, and the positive y axis goes upwards. The z axis points towards the viewer.

The array of vertices contains x, y and z coordinates of the triangle as well as a color for each vertex:

var vertices = [
 -1.0, -1.0, 0.0,    1.0, 0.0, 0.0,
 1.0, -1.0, 0.0,     0.0, 1.0, 0.0,
 0.0, 1.0, 0.0,      0.0, 0.0, 1.0
];

The vertex array is passed to the Float32Array which will be used as a buffer data source:

var vertexArray = new Float32Array(vertices);

The createBuffer() method of the WebGLRenderingContext generates a WebGLBuffer object:

vbo = gl.createBuffer();

The VBO is bound to the ARRAY_BUFFER target:

gl.bindBuffer(gl.ARRAY_BUFFER, vbo);

The VBO has been initialized as a zero-sized memory buffer, so it's high time to fill it with values:

gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);

// checking the validity of the VBO
console.log(gl.isBuffer(vbo));

The bufferData() command has written the contents of the vertex array to the VBO. The method employs one of three constants indicating the expected usage pattern of the buffer's data store:

  • STATIC_DRAW presumes that the values for the store will be specified once and used many times; this constant should be utilized for vertex data that is never changed;
  • DYNAMIC_DRAW shows that the data store contents will be frequently redefined;
  • STREAM_DRAW is helpful for transient geometry: the data is specified once and used just a few times.

WebGL supports another signature of the bufferData() method: sometimes the application logic may require that the size of a bound VBO should be set without indication of a real data source. The size of the buffer's data store is expressed in basic machine units:

var elementSize = Float32Array.BYTES_PER_ELEMENT;
gl.bufferData(gl.ARRAY_BUFFER, elementSize * vertices.length, gl.STATIC_DRAW);

Our demo application only deals with a single geometry object. In real-world applications, a vertex buffer object can contain lots of values for vertices, colors, normals and textures. There's a convenient bufferSubData() function allowing the developer to modify blocks of the buffer data dynamically, e.g. the following code replaces the blue color for the third vertex with yellow:

// arguments are target, offset, new data
gl.bufferSubData(gl.ARRAY_BUFFER, elementSize * 15, new Float32Array([1.0, 1.0, 0.0]));

Properties of the created VBO can be retrieved by calling the getBufferParameter():

// the buffer size is 72 bytes: 18 elements * 4 bytes per element
console.log(gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE));

// boolean comparison returns true
console.log(gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_USAGE) == gl.STATIC_DRAW);

Attribute Pointers

After the VBO has been successfully created, the getAttribLocation() method queries for the bindings of two attribute variables, then the vertexAttribPointer() describes the organization of the vertex attributes:

var position = gl.getAttribLocation(program, 'position'); // 0
gl.vertexAttribPointer(position, 3, gl.FLOAT, false, 6 * elementSize, 0);
gl.enableVertexAttribArray(position);

var color = gl.getAttribLocation(program, 'color'); // 1
gl.vertexAttribPointer(color, 3, gl.FLOAT, false, 6 * elementSize, 3 * elementSize);
gl.enableVertexAttribArray(color);

The vertexAttribPointer() command uses the following parameters:

  • index - the location of an attribute variable; our vertex shader has two variables (position and color) with 0 and 1 location indices;
  • size - the number of values per vertex; it is equal to 3 in the code snippet above because the vertex array contains data for 3 coordinates and 3 color components;
  • type - the data type of the values stored in the array; the numerals in the example belong to the FLOAT type;
  • normalized - a boolean value showing whether unsigned integer types are accepted directly or normalized to [0, 1]; signed types are normalized to [-1, 1];
  • stride - the byte offset between consecutive vertices; if the stride is 0, the vertices are supposed to be tightly packed in the vertex array;
  • offset - the byte offset which is 0 for coordinates and 3 for colors.

VBO creation can be summarized in a separate function as

function createVertexBufferObject() {
 var vertices = [
  -1.0, -1.0, 0.0,    1.0, 0.0, 0.0,
  1.0, -1.0, 0.0,     0.0, 1.0, 0.0,
  0.0, 1.0, 0.0,      0.0, 0.0, 1.0
 ];

 var vertexArray = new Float32Array(vertices);
 var elementSize = Float32Array.BYTES_PER_ELEMENT;

 vbo = gl.createBuffer();
 gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
 gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);

 var position = gl.getAttribLocation(program, 'position');
 gl.vertexAttribPointer(position, 3, gl.FLOAT, false, 6 * elementSize, 0);
 gl.enableVertexAttribArray(position);

 var color = gl.getAttribLocation(program, 'color');
 gl.vertexAttribPointer(color, 3, gl.FLOAT, false, 6 * elementSize, 3 * elementSize);
 gl.enableVertexAttribArray(color);

 gl.bindBuffer(gl.ARRAY_BUFFER, null);

 if(gl.getError() == gl.NO_ERROR) {
  prepareScene();
 } else {
  console.error('An error occurred while creating a vertex buffer object');
 }
}

Rendering

The prepareScene() function declares values for clearing the canvas and sets a comparison criterion for the depth test:

function prepareScene() {
 // default clearing values
 gl.clearColor(0.0, 0.0, 0.0, 0.0);
 gl.clearDepth(1.0);
 gl.enable(gl.DEPTH_TEST);
 gl.depthFunc(gl.LEQUAL);
 renderScene();
}

The renderScene() function renders graphics data to the WebGL drawing buffer:

function renderScene() {
 gl.clear(gl.COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT);
 gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
 gl.drawArrays(gl.TRIANGLES, 0, 3);
 gl.bindBuffer(gl.ARRAY_BUFFER, null);
}

The drawArrays() method accepts three parameters:

  • mode specifying what kind of primitives will be rendered; valid modes are LINES, LINE_LOOP, LINE_STRIP, POINTS, TRIANGLES, TRIANGLE_FAN and TRIANGLE_STRIP;
  • first - the starting index in the array;
  • count - the number of vertices to be rendered.