WebGL
In this lesson you’ll learn the basic structure of an HTML 5 3D graphics program, i.e., a WebGL program. The JavaScript portion of the WebGL program will follow the pattern of the examples at the end of the previous lesson.
Background
An image on the screen is composed of pixels, each a single color. In order to determine what color each pixel should be, geometric elements of a 3D scene are rasterized—they are broken up into single-color fragments, each corresponding to a pixel. In general, you want each fragment to be processed in the same way, and that processing can be done in parallel because the fragments are independent of each other. So there is a part of a WebGL program, called a fragment shader, where you put the code that can be run in parallel for each fragment.
What are the geometric elements of a 3D scene referred to in the previous paragraph—the things that are rasterized into fragments? Points, lines and triangles. The location of a point is specified by a single vertex. The location of a line is specified by two vertices; the location of a triangle by three. In general, you want each vertex to be processed in the same way, and that processing can be done in parallel because the vertices are independent of each other. Sound familiar? Just as there is a part of a WebGL program for parallel processing of fragments (the fragment shader), there is a part of a WebGL program for parallel processing of vertices, called a vertex shader.
Why are these parts of the program called shaders? In earlier versions of the OpenGL standard, there were built-in functions for typical operations used to specify vertex locations and fragment colors. Additional per-vertex and per-fragment operations, often used to create custom shading effects, were programmed in vertex and fragment shaders, which were optional at that time. Since OpenGL version 3.0 the built-in functions for specifying vertex locations and fragment colors have been deprecated. The WebGL standard (which is based on OpenGL) does not include these functions at all. So today vertex and fragment shaders aren’t just used for custom shading effects, they’re a part of any non-trivial WebGL (or OpenGL) program. Whatever can be done in parallel for each vertex goes in a vertex shader; whatever can be done in parallel for each fragment goes in a fragment shader.
Where do you put the main for your program, the part that isn’t meant to be parallelized? It’s written in JavaScript, run from within a web page written in HTML. Much of the challenge in WebGL programming is in getting data from the JavaScript part of the program to the vertex and fragment shaders. It will probably seem unecessarily complex at first, but it should make more sense as you get used to the idea that many instances of the shader code will run in parallel. All of the input data needs to be set up ahead of time and copied to a place where the shader code will have access to it; then the JavaScript program says “go” and everything happens at once. We’ll walk through this in detail with examples, starting from a trivial program that does not include shaders.
A Cleared Canvas
Your first WebGL program, meant to show how the HTML, CSS and JavaScript parts fit together, draws a solid black square.1 Here’s the HTML:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="style.css" />
<script src="square.js"></script>
</head>
<body>
<div id="content" style="width:300px">
<canvas id="canvas" width="300" height="300"></canvas>
</div>
</body>
</html>
In the head
section, the following two lines connect the HTML
file to a CSS file called “style.css” and a JavaScript file
called “square.js”. These will be described in detail below.
<link rel="stylesheet" href="style.css" />
<script src="square.js"></script>
One thing might seem strange if you have past experience with HTML:
<div id="content" style="width:300px">
<canvas id="canvas" width="300" height="300"></canvas>
</div>
Although there is an external stylesheet file (style.css), the
width
of the div
is specified inline. And the width
and
height
for the canvas
are specified in the tag as individual
properties rather than as styles. The reason height
and width
are set as individual properties is that it improves the result
when the 3D scene is rendered and displayed on the page. In order
for the styles specified in the CSS file (described below) to
work properly, the width
of the content
div
needs to match
the width
of the canvas
. So the width
of the content
div
is set inline, so that if later you want to change the
width
of the canvas
, you won’t have to go to the CSS file to
change the width
of the div
to make them match.
Here’s the CSS file, style.css:
@charset "UTF-8";
#content {
margin: auto;
margin-top: 40px;
}
The div
element of the HTML file, with the id content
, is used
here to center the canvas horizontally—margin
is set to
auto
. margin-top
is set to leave a little space between the
top of the page and the canvas. As with the HTML file, charset
is set to UTF-8.
Here’s the JavaScript file, square.js:
/*global document */
(function (global) {
"use strict";
global.onload = function () {
var canvas, gl;
canvas = document.getElementById("canvas");
gl = canvas.getContext("experimental-webgl");
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
};
}(this));
The function assigned to onload
will run when the browser has
finished loading the page’s HTML and CSS files. The two
assignment statements,
some version of which will be present in every WebGL program, get
a reference to the WebGL context object associated with the
canvas
element in the HTML file and assign it to gl
. The
next two lines…
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
…clear the canvas. The first line sets the color to which the
canvas should be cleared. The first three arguments represent red,
green and blue values; they’re all 0.0
, which gives you the
color black. If they were all 1.0
, you’d get white. (The
fourth value, called alpha, can be used for transparency and
other compositing effects. An alpha value of 1.0
would
generally be used to indicate a fully opaque color.) The second
line clears the canvas, specifically the buffer with color values
for each fragment. There are other buffers, with other
per-fragment values, which can be cleared if you pass other
arguments to the clear
method.
Exercise : Put square.html, square.js and style.css in a folder, run a local web server, open square.html in a browser… Your result should look like what you see in Figure 3.1 below.
A Red Square
The second example clears the canvas (to black) and then draws a red square. To do this we’ll add a vertex shader file, to process the four vertices of the square, and a fragment shader file, to color the fragments that make up the square. The HTML and CSS files won’t need to change, but we’ll have to add code to the JavaScript file to set up the shaders, put vertex data in a place where the vertex shader will have access to it, and to use the vertex and fragment shaders to draw the square.
First, the vertex shader, square.vert:
attribute vec3 position;
void main(void) {
gl_Position = vec4(position, 1.0);
}
An attribute
variable is used to pass per-vertex data from the
JavaScript part of a WebGL program to the vertex shader. In this
case, the variable is called position
and its type is vec3
,
which stores three floating-point values. A separate instance of
the vertex shader code will run (in parallel) for each vertex;
that is, a bunch of these will run at the same time, each starting
with position
equal to a different set of x, y, and z
coordinate values. We’ll see below how to get the values from
the JavaScript part of the program to these vertex shader
instances.
The main
of the vertex shader copies position
to the special
variable gl_Position
. Whatever is assigned to gl_Position
will be the position of the vertex. The type of gl_Position
,
however, is vec4
, so vec4(position, 1.0)
is used to add a
fourth value to position
before assigning it to gl_Position
.
What’s the significance of this fourth value? The vertex position
assigned to gl_Position
needs to be given in homogeneous
coordinates. We’ll see why homogeneous coordinates are used
later on this course. For now, its enough to know that a vertex
position $(x, y, z, w)$ in homogeneous coordinates is equivalent
to the position $(\frac{x}{w}, \frac{y}{w}, \frac{z}{w})$ in
standard coordinates. So $(x, y, z, 1)$, in homogeneous
coordinates, is equivalent to $(x, y, z)$, in standard coordinates.
Next, the fragment shader, square.frag:
void main(void) {
gl_FragColor = vec4(1.0, 0.1, 0.0, 1.0);
}
Unlike the vertex shader above, this fragment shader doesn’t get
any data from the JavaScript part of the WebGL program. (We’ll
see how to do that later.) It simply assigns a
four-float vector to the special variable gl_FragColor
. Just
as whatever is assigned to gl_Position
in a vertex shader will
be the vertex’s position, whatever is assigned to gl_FragColor
in a fragment shader will be the fragment’s color. The four
values are for red, blue, green and alpha (just like when we set
the clear color in the first example). So the color assigned to
gl_FragColor
here is (fully opaque) red, with a little bit of
green mixed in.
Now, on to the JavaScript file, square.js. We’ve added a lot of code. Keep in mind the purpose of the changes: we need to set up the shaders, put data in a place where the shaders will have access to it, and draw the red square.
/*jslint white: true */
/*global XMLHttpRequest, document, Float32Array */
(function (global) {
"use strict";
var readFile, gl, onVertexShaderLoad, vertexShader,
onFragmentShaderLoad, fragmentShader, main;
readFile = function (path, callback) {
var r = new XMLHttpRequest();
r.onreadystatechange = function () {
if (r.readyState === 4 && r.status === 200) {
callback(r.responseText);
}
};
r.open("GET", path, true);
r.send();
};
global.onload = function () {
gl = document.getElementById("canvas").getContext(
"experimental-webgl");
// Request vertex shader source.
readFile("square.vert", onVertexShaderLoad);
};
onVertexShaderLoad = function (shaderSource) {
// Once vertex shader source is loaded, create vertex
// shader object for it; compile it.
vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, shaderSource);
gl.compileShader(vertexShader);
// Request fragment shader source.
readFile("square.frag", onFragmentShaderLoad);
};
onFragmentShaderLoad = function (shaderSource) {
// Once fragment shader source is loaded...
fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, shaderSource);
gl.compileShader(fragmentShader);
// Continue with the rest of the program.
main();
};
main = function () {
var shaderProgram, vertexData, vertexBuffer,
vertexPositionAttribute;
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Set up shader program (based on compiled vertex
// and fragment shaders).
shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
gl.useProgram(shaderProgram);
// Put vertex data (coordinates for the four corners of a
// square) in a buffer where the vertex shader will have
// access to it.
vertexData = [
-0.5, 0.5, 0.0, // x = -0.5, y = 0.5, z = 0.0
0.5, 0.5, 0.0,
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0 ];
vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexData),
gl.STATIC_DRAW);
// Set things up so that it will be possible to copy data
// from a buffer to the vertex shader variable "position."
vertexPositionAttribute = gl.getAttribLocation(shaderProgram,
"position");
gl.enableVertexAttribArray(vertexPositionAttribute);
// Clear the canvas.
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Specify which buffer vertex data will come from, and how
// that data should be interpreted; then draw the square.
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT,
false, 12, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
}(this));
The readFile
function is used to read text from a file
accessible via a URL. The function assigned to onload
calls
readFile
to get the vertex shader source code, specifying that
onVertexShaderLoad
should be called when the vertex shader
source is available. onVertexShaderLoad
creates vertexShader
,
through which the shader source code can be compiled and (later
on in main
) made part of the shader program used to generate
the image. After this onVertexShaderLoad
calls readFile
to
get the fragment shader source code.
Once the fragment shader source is available,
onFragmentShaderLoad
will be called to compile the fragment
shader source. After this onFragmentShaderLoad
will call main
to run the rest of the program.
In main, after setting up the shader program from the vertex and fragment shader source code, the coordinates of a square’s four vertices are put into a JavaScript Array object:
vertexData = [
-0.5, 0.5, 0.0, // x = -0.5, y = 0.5, z = 0.0
0.5, 0.5, 0.0,
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0 ];
To understand these numbers, you need to know something about the
coordinate system of the 3D scene generated by WebGL and displayed
when the HTML file’s canvas
element is rendered by the browser
and displayed on the page. Like the OpenGL API it’s based on,
WebGL clips the 3D scene so that it extends from $-1$ to $1$ in
the $x$, $y$ and $z$ directions. The bottom left front corner is
$(-1, -1, -1)$; the top right back corner is $(1, 1, 1)$. This is
true regardless of the size or aspect ratio of the canvas
element set in the HTML and CSS files. So the vertex coordinates
given above specify a square on the $z = 0$ plane, extending
halfway from the center to the outside edge of the scene in the
$x$ and $y$ directions.
The next three statements put this data in a place where the vertex shader will have access to it.
vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexData),
gl.STATIC_DRAW);
The first statement creates a WebGL buffer, the second tells WebGL
that this newly created buffer is the one we want to work with,
and the third statement copies the data from vertexData
to the
new WebGL buffer. The constructor Float32Array
is used to
convert the data from a JavaScript Array object to a true array;
gl.STATIC_DRAW
is to indicate that the data in the buffer
won’t change. WebGL may be able to use this information to
improve the performance of the program.
Next, we tell WebGL that the vertex shader’s position
variable
is a possible destination for per-vertex data coming from a
buffer.
vertexPositionAttribute = gl.getAttribLocation(shaderProgram,
"position");
gl.enableVertexAttribArray(vertexPositionAttribute);
Then, after clearing the canvas, we draw the square:
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT,
false, 12, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
The first statement (which we’ve seen before) tells WebGL that
vertexBuffer
is the buffer we want to work with. The second
tells how the data from the buffer should be interpreted:
it should go to the vertex shader’s position
variable
(referred to via vertexPositionAttribute
), there are three
values per vertex, they are float values, there are 12 bytes
between the beginning one vertex’s data and the next vertex’s
data (since each value is a 32-bit float), and the first value
is at the first byte of the buffer. (false
indicates that
nonfloating data should not be normalized—we’re sending float
values, so this is irrelevant.)
The third statement is where we actually draw the square.
gl.TRIANGLE_STRIP
means we’ll draw a strip of connected
triangles: the first three vertices form the first triangle;
the second, third and fourth form the second triangle. (If there
were more vertices the third, fourth and fifth would form the
next triangle, the fourth, fifth and sixth would form the next,
etc.)
Exercise : Put the five files from this example in a folder, open square.html in a web browser… Figure 3.2 shows what I expect you to see.
Exercise : Modify this example so that the canvas size on the page is
400x300. Notice that the square is no longer square. (It’s
rectangular.) Change the values of vertexData
so that it
becomes square again (and is still centered in the canvas).
Exercise : Modify this example so that it uses the readFiles
function
you wrote for the second JavaScript lesson to load the two
shader files concurrently.
A More Colorful Square
This example introduces two new types of shader variables (in
addition to attribute
variables, introduced in the previous
example): uniform
variables, which are used to pass, from the
JavaScript part of the program to the shaders, values that are the
same for all vertices and fragments, and varying
variables,
which are used to pass data from a vertex shader to a fragment
shader. You’ll see below why the word varying
is used to
describe them.
The HTML and CSS files stay the same. Here’s the vertex shader, square.vert:
attribute vec3 position;
uniform float blueValue;
varying vec3 color;
void main(void) {
gl_Position = vec4(position, 1.0);
color = vec3(position.x + 0.5, position.y + 0.5, blueValue);
}
There are two new global variables in the vertex shader,
blueValue
and color
. blueValue
is a uniform
variable.
It gets its value from the JavaScript part of the program, and
its value is the same for all vertices. uniform
variables can
also be used to pass values from the JavaScript part of the
program directly to a fragment shader. In that case the value
of the uniform
is the same for all fragments.
color
is a varying
variable. This means it will be assigned
a per-vertex value in the vertex shader. It will have a
per-fragment value in the fragment shader, however. The value
a fragment gets will be interpolated; for example, if a fragment
is positioned halfway between a vertex for which the varying
variable is assigned 0.0
and a vertex for which the varying
variable is assigned 1.0
, the fragment will get 0.5
.
One more thing to notice about the vertex shader: the OpenGL
Shading Language (GLSL), the language vertex and fragment shaders
are written in, allows you to access the elements of a vec3
variable using .x
or .y
. This is called swizzling and is an
interesting example of how a special purpose programming language
can include very specific data types and features to make programs
more readable. You can use .z
to access the third value. In
fact, you can also use .r
, .g
and .b
as an alternative to
.x
, .y
and .z
, since vec3
variables are often used to store
colors.
Here’s the line that assigns a value to the varying
variable
color
in the vertex shader:
color = vec3(position.x + 0.5, position.y + 0.5, blueValue);
color
needs three values: red, green and blue. The red value
here is based on the $x$ coordinate of the vertex. The coordinate
value is in the range -0.5
to 0.5
, but the red value should
be in the range 0.0
to 1.0
, so we add 0.5
. Likewise with
the green value. The blue value is blueValue
, which will be
set in the JavaScript part of the program.
Here’s the fragment shader, square.frag:
varying lowp vec3 color;
void main(void) {
gl_FragColor = vec4(color, 1.0);
}
Notice that here color
is declared with the precision qualifier
lowp
. Many fragment shader instances will run in parallel, and
they’ll each have their own copy of all per-fragment variables (like
color
). So it’s important to minimize the amount of memory
required. Because of this the precision of fragment shader
variables must be specified. lowp
, for low precision, is
guaranteed to provide enough precision for color values, and should
be used whenever possible. (Higher-precision alternatives are
mediump
and highp
.)
You need to add the two statements below to square.js, just before
clearing the canvas
and drawing the square. (You’ll also need
to declare the variable blueValueUniform
at the beginning of
main
.) These statements set the
value of the vertex shader’s uniform
variable blueValue
to a
random number between 0.0
and 1.0
.
blueValueUniform = gl.getUniformLocation(shaderProgram,
"blueValue");
gl.uniform1f(blueValueUniform, Math.random());
With these changes, you get the image in Figure 3.3
(depending on the random value assigned to blueValue
).
Exercise : gl.TRIANGLE_FAN
is an alternative to gl.TRIANGLE_STRIP
.
With gl.TRIANGLE_FAN
, all triangles share the first vertex
specified: the first three vertices form the first triangle,
the first, third and fourth form the second triangle, the
first, fourth and fifth form the third, and so on. Using
gl.TRIANGLE_FAN
, modify the second example (“A Red Square”)
so that it draws a yellow octagon. Make the center of the
octagon the first vertex.)
Exercise : After doing the previous exercise, modify the third example (“A More Colorful Square”) so that it draws an octagon with a yellow center, fading to orange at the outside.
-
Based on an example from chapter 2 of the WebGL Programming Guide, by Kouichi Matsuda and Roger Lea. ↩