The first thing to understand about WebGL is that unlike the broader OpenGL standard, WebGL does not have inherent support for lighting. You have to do it yourself. Fortunately, it's not all that hard to do, and this article will cover some of the basics.
Simulating lighting and shading in 3D
Although going into detail about the theory behind simulated lighting in 3D graphics is far beyond the scope of this article, it's helpful to know a bit about how it works. Instead of discussing it in depth here, take a look at the article on Phong shading at Wikipedia, which provides a good overview of the most commonly used lighting model.
There are three basic types of lighting:
Ambient light is the light that permeates the scene; it's non-directional and affects every face in the scene equally, regardless of which direction it's facing.
Directional light is light that is emitted from a specific direction. This is light that's coming from so far away that every photon is moving parallel to every other photon. Sunlight, for example, is directional light.
Point light is light that is being emitted from a point, radiating in all directions. This is how many real-world light sources usually work. A light bulb emits light in all directions, for example.
For our purposes, we're going to simplify the lighting model by only considering simple directional and ambient lighting; we won't have any specular highlights or point light sources in this scene. Instead, we'll have our ambient lighting plus a single directional light source, aimed at the rotating cube from the previous demo.
Once you drop out the concept of point sources and specular lighting, there are two pieces of information we'll need in order to implement our directional lighting:
- We need to associate a surface normal with each vertex. This is a vector that's perpendicular to the face at that vertex.
- We need to know the direction in which the light is traveling; this is defined by the direction vector.
Then we update the vertex shader to adjust the color of each vertex, taking into account the ambient lighting as well as the effect of the directional lighting given the angle at which it's striking the face. We'll see how to do that when we look at the code for the shader.
Building the normals for the vertices
The first thing we need to do is generate the array of normals for all the vertices that comprise our cube. Since a cube is a very simple object, this is easy to do; obviously for more complex objects, calculating the normals will be more involved.
cubeVerticesNormalBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesNormalBuffer); var vertexNormals = [ // Front 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // Back 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, // Top 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, // Bottom 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, // Right 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, // Left -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0 ]; gl.bufferData(gl.ARRAY_BUFFER, new WebGLFloatArray(vertexNormals), gl.STATIC_DRAW);
This should look pretty familiar by now; we create a new buffer, bind it to be the array we're working with, then send along our array of vertex normals into the buffer by calling bufferData()
.
Then we add the code to drawScene()
to bind the normals array to a shader attribute so the shader code can get access to it:
gl.bindBuffer(gl.ARRAY_BUFFER, cubeVerticesNormalBuffer); gl.vertexAttribPointer(vertexNormalAttribute, 3, gl.FLOAT, false, 0, 0);
Finally, we need to update the code that builds the uniform matrices to generate and deliver to the shader a normal matrix, which is used to transform the normals when dealing with the current orientation of the cube in relation to the light source:
var normalMatrix = mvMatrix.inverse(); normalMatrix = normalMatrix.transpose(); var nUniform = gl.getUniformLocation(shaderProgram, "uNormalMatrix"); gl.uniformMatrix4fv(nUniform, false, new WebGLFloatArray(normalMatrix.flatten()));
Update the shaders
Now that all the data the shaders need is available to them, we need to update the code in the shaders themselves.
The vertex shader
The first thing to do is update the vertex shader so it generates a shading value for each vertex based on the ambient lighting as well as the directional lighting. Let's take a look at the code:
<script id="shader-vs" type="x-shader/x-vertex"> attribute highp vec3 aVertexNormal; attribute highp vec3 aVertexPosition; attribute highp vec2 aTextureCoord; uniform highp mat4 uNormalMatrix; uniform highp mat4 uMVMatrix; uniform highp mat4 uPMatrix; varying highp vec2 vTextureCoord; varying highp vec3 vLighting; void main(void) { gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0); vTextureCoord = aTextureCoord; // Apply lighting effect highp vec3 ambientLight = vec3(0.6, 0.6, 0.6); highp vec3 directionalLightColor = vec3(0.5, 0.5, 0.75); highp vec3 directionalVector = vec3(0.85, 0.8, 0.75); highp vec4 transformedNormal = uNormalMatrix * vec4(aVertexNormal, 1.0); highp float directional = max(dot(transformedNormal.xyz, directionalVector), 0.0); vLighting = ambientLight + (directionalLightColor * directional); } </script>
Once the position of the vertex is computed, and we obtain the coordinates of the texel corresponding to the vertex, we can work on computing the shading for the vertex.
The first thing we do is transform the normal based on the current position and orientation of the cube, by multiplying the vertex's normal by the normal matrix. We can then compute the amount of directional lighting that needs to be applied to the vertex by calculating the dot product of the transformed normal and the directional vector (that is, the direction from which the light is coming). If this value is less than zero, then we pin the value to zero, since you can't have less than zero light.
Once the amount of directional lighting is computed, we can generate the lighting value by taking the ambient light and adding in the product of the directional light's color and the amount of directional lighting to provide. As a result, we now have an RGB value that will be used by the fragment shader to adjust the color of each pixel we render.
The fragment shader
The fragment shader now needs to be updated to take into account the lighting value computed by the vertex shader:
<script id="shader-fs" type="x-shader/x-fragment"> varying highp vec2 vTextureCoord; varying highp vec3 vLighting; uniform sampler2D uSampler; void main(void) { mediump vec4 texelColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t)); gl_FragColor = vec4(texelColor.rgb * vLighting, texelColor.a); } </script>
Here we fetch the color of the texel, just like we did in the previous example, but before setting the color of the fragment, we multiply the texel's color by the lighting value to adjust the texel's color to take into account the effect of our light sources.
And that's it!
View the complete code | Open this demo on a new page
Exercises for the reader
Obviously, this is a simple example, implementing basic per-vertex lighting. For more advanced graphics, you'll want to implement per-pixel lighting, but this will get you headed in the right direction.
You might also try experimenting with the direction of the light source, the colors of the light sources, and so forth.