My original plan for this post was to talk about a very cool lighting technique I learned only recently. Since I’ve been meaning to play with WebGL, I thought I could demonstrate the effect here, live in the browser. While I was preparing the demo, I did get the chance to learn a lot more about WebGL and at the same time, work a bit more about jQuery and javascript. And, as is often the case, got distracted from my original goal and spent most of my “posting time” just fooling around with the setup. So the lighting demo will have to wait for another time, but today I’ll share some of the jQuery plugin utilities I wrote that made some of the WebGL setup a little bit easier for me.

If, like me, you haven’t had much time to check out WebGL yet, Savas Ziplies has been writing an introductory series here on #AltDevBlogADay you might want to check out:







The demo

For comparison, I’ve just rewritten Chromium teapot demo. Here’s the original WebGL version: shiny-teapot

Graphically there’s really no difference. (Click and drag teapot to rotate.)
See the source: demo.js

The jQuery plugins

In the process of working on many variations of the aforementioned lighting demo, there was a lot of WebGL housekeeping setup that I had to do over and over. Looking over the data in the source shader and model files, it seemed like all of that information was already there. I was just typing it in multiple times. And making multiple mistakes doing it! One of the first rules of usability is The Default Behavior should be The Right Behavior, most of the time. I didn’t find that to be true with the WebGL setup. It’s not a lot different from the typical OpenGL (or DirectX) interface in that regard.

However, since everything with WebGL is done in Javascript, and the shaders are compiled from text, I figured all the information is there to simplify the setup process and I wanted to try my hand at writing some jQuery plugins to do it. I’m no jQuery or javascript ninja, so this was also a good opportunity for me to play around a bit more there too.

I wrote three basic plugins:

  • $.glProgram which handles loading and compiling of fragment and vertex shaders.
  • $.glModel which handles loading of JSON model files.
  • $.glTexture which handles loading images and building textures and cube maps from them.

You can find the source of the plugins here: macton-gl-utils.js

$.glProgram

Loading a program with the plugin is very straightforward. Just specify the vertex and fragment shaders. The plugin will load the shader source files (from external files) and compile them into a gl program. The plugin will also scan the source and find all the uniforms and attributes automatically. The uniform locations will be cached along with the types used for both the uniforms and attributes. Because the locations and type information is now available, it can be used (later) to simplify the binding process.

Here’s how the teapot program is loaded with the plugin:
You can also see the shader sources: bump_reflect.vs

1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  
  var bump_reflect_program_config = 
 
    {
 
      VertexProgramURL:   './shaders/bump_reflect.vs',
 
      FragmentProgramURL: './shaders/bump_reflect.fs',
 
    };
 
    bump_reflect_program = new $.glProgram( gl, bump_reflect_program_config, ProgramLoaded );

Below is how it was loaded in the original demo. Note:

  • The vertex and fragment program sources were defined inline. (They’re much easier to work with as separate source files.)
  • The uniforms locations and cached by name, even though that information is already available in the source
  • The attributes are bound by name. Again that information is available in the source. And there’s not likely to be something else you’re going to want to do with attributes other than bind them.
  • The source files and compiled and linked into a program manually. Even though (again), I’m not sure what else you’d want to do with them besides that.
1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  7
 
  8
 
  9
 
  10
 
  11
 
  12
 
  13
 
  14
 
  15
 
  16
 
  17
 
  18
 
  19
 
  20
 
  21
 
  22
 
  23
 
  24
 
  25
 
  26
 
  27
 
  28
 
  29
 
  30
 
  31
 
  32
 
  33
 
  34
 
  35
 
  36
 
  37
 
  38
 
  39
 
  40
 
  41
 
  42
 
  43
 
  44
 
  45
 
  46
 
  47
 
  48
 
  49
 
  50
 
  51
 
  52
 
  53
 
  54
 
  55
 
  56
 
  57
 
  58
 
  59
 
  60
 
  61
 
  62
 
  63
 
  64
 
  65
 
  66
 
  67
 
  68
 
  69
 
  70
 
  71
 
  72
 
  73
 
  74
 
  75
 
  76
 
  77
 
  78
 
  79
 
  80
 
  81
 
  82
 
  83
 
  84
 
  85
 
  86
 
  87
 
  88
 
  89
 
  90
 
  91
 
  92
 
  93
 
  94
 
  95
 
  96
 
  97
 
  98
 
  99
 
  100
 
  101
 
  102
 
  103
 
  104
 
  105
 
  106
 
  107
 
  108
 
  109
 
  110
 
  111
 
  112
 
  113
 
  114
 
  
var bumpReflectVertexSource = [
 
      "attribute vec3 g_Position;",
 
      "attribute vec3 g_TexCoord0;",
 
      "attribute vec3 g_Tangent;",
 
      "attribute vec3 g_Binormal;",
 
      "attribute vec3 g_Normal;",
 
      "",
 
      "uniform mat4 world;",
 
      "uniform mat4 worldInverseTranspose;",
 
      "uniform mat4 worldViewProj;",
 
      "uniform mat4 viewInverse;",
 
      "",
 
      "varying vec2 texCoord;",
 
      "varying vec3 worldEyeVec;",
 
      "varying vec3 worldNormal;",
 
      "varying vec3 worldTangent;",
 
      "varying vec3 worldBinorm;",
 
      "",
 
      "void main() {",
 
      "  gl_Position = worldViewProj * vec4(g_Position.xyz, 1.);",
 
      "  texCoord.xy = g_TexCoord0.xy;",
 
      "  worldNormal = (worldInverseTranspose * vec4(g_Normal, 1.)).xyz;",
 
      "  worldTangent = (worldInverseTranspose * vec4(g_Tangent, 1.)).xyz;",
 
      "  worldBinorm = (worldInverseTranspose * vec4(g_Binormal, 1.)).xyz;",
 
      "  vec3 worldPos = (world * vec4(g_Position, 1.)).xyz;",
 
      "  worldEyeVec = normalize(worldPos - viewInverse[3].xyz);",
 
      "}"
 
      ].join("\n");
 
   
 
  var bumpReflectFragmentSource = [
 
      "#ifdef GL_ES\n",
 
      "precision highp float;\n",
 
      "#endif\n",
 
      "const float bumpHeight = 0.2;",
 
      "",
 
      "uniform sampler2D normalSampler;",
 
      "uniform samplerCube envSampler;",
 
      "",
 
      "varying vec2 texCoord;",
 
      "varying vec3 worldEyeVec;",
 
      "varying vec3 worldNormal;",
 
      "varying vec3 worldTangent;",
 
      "varying vec3 worldBinorm;",
 
      "",
 
      "void main() {",
 
      "  vec2 bump = (texture2D(normalSampler, texCoord.xy).xy * 2.0 - 1.0) * bumpHeight;",
 
      "  vec3 normal = normalize(worldNormal);",
 
      "  vec3 tangent = normalize(worldTangent);",
 
      "  vec3 binormal = normalize(worldBinorm);",
 
      "  vec3 nb = normal + bump.x * tangent + bump.y * binormal;",
 
      "  nb = normalize(nb);",
 
      "  vec3 worldEye = normalize(worldEyeVec);",
 
      "  vec3 lookup = reflect(worldEye, nb);",
 
      "  vec4 color = textureCube(envSampler, lookup);",
 
      "  gl_FragColor = color;",
 
      "}"
 
      ].join("\n");
 
   
 
  function loadShader(type, shaderSrc) {
 
      var shader = gl.createShader(type);
 
      if (shader == null) {
 
          return null;
 
      }
 
      // Load the shader source
 
      gl.shaderSource(shader, shaderSrc);
 
      // Compile the shader
 
      gl.compileShader(shader);
 
      // Check the compile status
 
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
 
          var infoLog = gl.getShaderInfoLog(shader);
 
          output("Error compiling shader:\n" + infoLog);
 
          gl.deleteShader(shader);
 
          return null;
 
      }
 
      return shader;
 
  }
 
   
 
  function initShaders() {
 
      var vertexShader = loadShader(gl.VERTEX_SHADER, bumpReflectVertexSource);
 
      var fragmentShader = loadShader(gl.FRAGMENT_SHADER, bumpReflectFragmentSource);
 
      // Create the program object
 
      var programObject = gl.createProgram();
 
      if (programObject == null) {
 
          output("Creating program failed");
 
          return;
 
      }
 
      gl.attachShader(programObject, vertexShader);
 
      gl.attachShader(programObject, fragmentShader);
 
      // Bind attributes
 
      gl.bindAttribLocation(programObject, 0, "g_Position");
 
      gl.bindAttribLocation(programObject, 1, "g_TexCoord0");
 
      gl.bindAttribLocation(programObject, 2, "g_Tangent");
 
      gl.bindAttribLocation(programObject, 3, "g_Binormal");
 
      gl.bindAttribLocation(programObject, 4, "g_Normal");
 
      // Link the program
 
      gl.linkProgram(programObject);
 
      // Check the link status
 
      var linked = gl.getProgramParameter(programObject, gl.LINK_STATUS);
 
      if (!linked) {
 
          var infoLog = gl.getProgramInfoLog(programObject);
 
          output("Error linking program:\n" + infoLog);
 
          gl.deleteProgram(programObject);
 
          return;
 
      }
 
      g_programObject = programObject;
 
      // Look up uniform locations
 
      g_worldLoc = gl.getUniformLocation(g_programObject, "world");
 
      g_worldInverseTransposeLoc = gl.getUniformLocation(g_programObject, "worldInverseTranspose");
 
      g_worldViewProjLoc = gl.getUniformLocation(g_programObject, "worldViewProj");
 
      g_viewInverseLoc = gl.getUniformLocation(g_programObject, "viewInverse");
 
      g_normalSamplerLoc = gl.getUniformLocation(g_programObject, "normalSampler");
 
      g_envSamplerLoc = gl.getUniformLocation(g_programObject, "envSampler");
 
      checkGLError();
 
  }

$.glModel

Loading a model with the plugin is even easier than loading a program! Just specify the model JSON data. That’s it. The vertex streams in the JSON data are named, so the plugin can work out that those are attributes that need to (ultimately) be bound to a program and that information is cached. The types and strides are also specified in the JSON data, so the correct fixed array types (e.g. Float32Array) for the WebGL buffers are determined based on those types.

Here is how the teapot model is loaded with the plugin: (You can also see the json file for the teapot here: teapot.json)

1
 
  
teapot_model         = new $.glModel(   gl, './models/teapot.json',      ModelLoaded       );

Below is how it was loaded in the original demo. Note:

  • The teapot model was stored inline as a javascript variable (much easier to work with as a separate json file). Not shown here for length.
  • The buffer sizes and types need to be worked out and bound even though that’s easily worked out from the source data.
1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  7
 
  8
 
  9
 
  10
 
  11
 
  12
 
  13
 
  14
 
  15
 
  16
 
  17
 
  18
 
  19
 
  20
 
  21
 
  22
 
  23
 
  24
 
  25
 
  
function initTeapot() {
 
      g_vbo = gl.createBuffer();
 
      gl.bindBuffer(gl.ARRAY_BUFFER, g_vbo);
 
      gl.bufferData(gl.ARRAY_BUFFER,
 
                    teapotPositions.byteLength +
 
                    teapotNormals.byteLength +
 
                    teapotTangents.byteLength +
 
                    teapotBinormals.byteLength +
 
                    teapotTexCoords.byteLength,
 
                    gl.STATIC_DRAW);
 
      g_normalsOffset = teapotPositions.byteLength;
 
      g_tangentsOffset = g_normalsOffset + teapotNormals.byteLength;
 
      g_binormalsOffset = g_tangentsOffset + teapotTangents.byteLength;
 
      g_texCoordsOffset = g_binormalsOffset + teapotBinormals.byteLength;
 
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, teapotPositions);
 
      gl.bufferSubData(gl.ARRAY_BUFFER, g_normalsOffset, teapotNormals);
 
      gl.bufferSubData(gl.ARRAY_BUFFER, g_tangentsOffset, teapotTangents);
 
      gl.bufferSubData(gl.ARRAY_BUFFER, g_binormalsOffset, teapotBinormals);
 
      gl.bufferSubData(gl.ARRAY_BUFFER, g_texCoordsOffset, teapotTexCoords);
 
   
 
      g_elementVbo = gl.createBuffer();
 
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g_elementVbo);
 
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, teapotIndices, gl.STATIC_DRAW);
 
      g_numElements = teapotIndices.length;
 
  }

$.glTexture

Textures are a bit more complicated since there are a lot of user settings per texture that can’t be worked out from the source. For this plugin, the user settings for each texture are just moved into a config object. The details of setting up TextureParameters and ImageStoreParameters based on those is worked out in the obvious way. The textures are automatically created and bound after the images have been loaded.

Here is how the teapot bump texture is loaded with the plugin:

1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  7
 
  8
 
  9
 
  10
 
  11
 
  12
 
  13
 
  14
 
  15
 
  16
 
  17
 
  
  var bump_texture_config =
 
    {
 
      Type:      'TEXTURE_2D',
 
      ImageURL:  './images/bump.jpg',
 
      TexParameters: 
 
      {
 
        TEXTURE_MIN_FILTER: 'LINEAR',
 
        TEXTURE_MAG_FILTER: 'LINEAR',
 
        TEXTURE_WRAP_S:     'REPEAT',
 
        TEXTURE_WRAP_T:     'REPEAT'
 
      },
 
      PixelStoreParameters:
 
      {
 
        UNPACK_FLIP_Y_WEBGL: true
 
      },
 
    };
 
    bump_texture         = new $.glTexture( gl, bump_texture_config,         bump_textureLoaded );

Here is how the teapot environment map is loaded with the plugin:

1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  7
 
  8
 
  9
 
  10
 
  11
 
  12
 
  13
 
  14
 
  15
 
  16
 
  17
 
  18
 
  19
 
  20
 
  21
 
  22
 
  23
 
  24
 
  25
 
  26
 
  27
 
  28
 
  29
 
  30
 
  31
 
  32
 
  33
 
  34
 
  
  var env_texture_config =
 
    {
 
      Type: 'TEXTURE_CUBE_MAP',
 
      ImageURL: 
 
      [ 
 
        './images/skybox-posx.jpg',
 
        './images/skybox-negx.jpg',
 
        './images/skybox-posy.jpg',
 
        './images/skybox-negy.jpg',
 
        './images/skybox-posz.jpg',
 
        './images/skybox-negz.jpg' 
 
      ],
 
      ImageType: 
 
      [ 
 
        'TEXTURE_CUBE_MAP_POSITIVE_X',
 
        'TEXTURE_CUBE_MAP_NEGATIVE_X',
 
        'TEXTURE_CUBE_MAP_POSITIVE_Y',
 
        'TEXTURE_CUBE_MAP_NEGATIVE_Y',
 
        'TEXTURE_CUBE_MAP_POSITIVE_Z',
 
        'TEXTURE_CUBE_MAP_NEGATIVE_Z'
 
      ],
 
      TexParameters: 
 
      {
 
        TEXTURE_WRAP_S:     'CLAMP_TO_EDGE',
 
        TEXTURE_WRAP_T:     'CLAMP_TO_EDGE',
 
        TEXTURE_MIN_FILTER: 'LINEAR',
 
        TEXTURE_MAG_FILTER: 'LINEAR'
 
      },
 
      PixelStoreParameters:
 
      {
 
        UNPACK_FLIP_Y_WEBGL: false
 
      }
 
    };
 
    env_texture          = new $.glTexture( gl, env_texture_config,          env_textureLoaded  );

Binding Attributes

This is were things really start to get nice! Since the attributes and vertex streams are all cached by the $.glModel and the $.glProgram, all we need to do to bind them together is make one call and then pass the model to GL for drawing.

1
 
  2
 
  
    bump_reflect_program.BindModel( teapot_model, TeapotBumpReflectAttributeBindings );
 
      bump_reflect_program.DrawModel();

In order to automatically bind the model to the program (as above) a mapping from the names used in one (stream names in model) to the other (attribute names in shader source) must be provided. It looks like this:

1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  7
 
  8
 
  
var TeapotBumpReflectAttributeBindings =
 
  { 
 
    Positions: 'g_Position', 
 
    Texcoords: 'g_TexCoord0', 
 
    Tangents:  'g_Tangent', 
 
    Binormals: 'g_Binormal',
 
    Normals:   'g_Normal'
 
  };

However, one thing I noticed was that very often the names were close, but not exactly the same. I felt it shouldn’t be necessary to specify them when it’s obvious (as in the above example) what should be mapped to what. I wrote a quick utility that uses the Levenshtein Distance between the two pairs of strings to find the best matches and build the above binding object automatically.

It’s used like this in the teapot demo: (The mapping above is created automatically here.)

1
 
  
  TeapotBumpReflectAttributeBindings = bump_reflect_program.CreateBestVertexBindings( teapot_model );

Here is how the attributes were bound and the model drawn in the original demo. Note:

  • The stream offsets are tracked manually.
  • The attribute indices are supplied inline and correspond to the bindings in the program. Very easy to get out of sync if you’re changing around attributes a lot!
1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  7
 
  8
 
  9
 
  10
 
  11
 
  12
 
  13
 
  14
 
  15
 
  
    // Bind and set up vertex streams
 
      gl.bindBuffer(gl.ARRAY_BUFFER, g_vbo);
 
      gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
 
      gl.enableVertexAttribArray(0);
 
      gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, g_texCoordsOffset);
 
      gl.enableVertexAttribArray(1);
 
      gl.vertexAttribPointer(2, 3, gl.FLOAT, false, 0, g_tangentsOffset);
 
      gl.enableVertexAttribArray(2);
 
      gl.vertexAttribPointer(3, 3, gl.FLOAT, false, 0, g_binormalsOffset);
 
      gl.enableVertexAttribArray(3);
 
      gl.vertexAttribPointer(4, 3, gl.FLOAT, false, 0, g_normalsOffset);
 
      gl.enableVertexAttribArray(4);
 
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, g_elementVbo);
 
      checkGLError();
 
      gl.drawElements(gl.TRIANGLES, g_numElements, gl.UNSIGNED_SHORT, 0);

Binding Uniforms

Like attributes, uniforms are made much simpler. The type of each uniform is known from the source files (e.g. mat4 or sample2D) so there is really only one correct way to bind it and only one correct type to bind it with. Texture slots are given to textures automatically as they are bound and don’t need to be specified.

Binding the uniforms for the teapot looks like this with the plugins:

1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  
    bump_reflect_program.BindUniform( 'world',                 model.elements );
 
      bump_reflect_program.BindUniform( 'worldInverseTranspose', worldInverseTranspose.elements );
 
      bump_reflect_program.BindUniform( 'worldViewProj',         mvp.elements );
 
      bump_reflect_program.BindUniform( 'viewInverse',           viewInverse.elements );
 
      bump_reflect_program.BindUniform( 'normalSampler',         bump_texture );
 
      bump_reflect_program.BindUniform( 'envSampler',            env_texture );

Here is how the uniforms were bound in the original demo. Note:

  • Separate variables to cache the uniform locations had to be kept. Even though you would always want that to be done.
  • The type of the fixed array had to be specified (e.g. Float32Array) even though there is only one likely acceptable type given the attribute type in the source file (e.g. mat4)
  • Texture indices are bound manually. Easy to break if you’re changing texture and sampler setups a lot!
1
 
  2
 
  3
 
  4
 
  5
 
  6
 
  7
 
  8
 
  9
 
  10
 
  11
 
  
    // Set up uniforms
 
      gl.uniformMatrix4fv(g_worldLoc, gl.FALSE, new Float32Array(model.elements));
 
      gl.uniformMatrix4fv(g_worldInverseTransposeLoc, gl.FALSE, new Float32Array(worldInverseTranspose.elements));
 
      gl.uniformMatrix4fv(g_worldViewProjLoc, gl.FALSE, new Float32Array(mvp.elements));
 
      gl.uniformMatrix4fv(g_viewInverseLoc, gl.FALSE, new Float32Array(viewInverse.elements));
 
      gl.activeTexture(gl.TEXTURE0);
 
      gl.bindTexture(gl.TEXTURE_2D, g_bumpTexture);
 
      gl.uniform1i(g_normalSamplerLoc, 0);
 
      gl.activeTexture(gl.TEXTURE1);
 
      gl.bindTexture(gl.TEXTURE_CUBE_MAP, g_envTexture);
 
      gl.uniform1i(g_envSamplerLoc, 1);

End

You’re welcome to use the plugins if you like. For me, it was mostly an exercise to get to know WebGL for the first time and to learn a bit more about jQuery and javascipt. There are also some WebGL “frameworks” out there that also do manage this kind of setup for you, but there’s nothing like doing it yourself (at least once) to learn how it’s done.

Mike.