\( \def\sc#1{\dosc#1\csod} \def\dosc#1#2\csod{{\rm #1{\small #2}}} \newcommand{\dee}{\mathrm{d}} \newcommand{\Dee}{\mathrm{D}} \newcommand{\In}{\mathrm{in}} \newcommand{\Out}{\mathrm{out}} \newcommand{\pdf}{\mathrm{pdf}} \newcommand{\Cov}{\mathrm{Cov}} \newcommand{\Var}{\mathrm{Var}} \newcommand{\ve}[1]{\mathbf{#1}} \newcommand{\mrm}[1]{\mathrm{#1}} \newcommand{\ves}[1]{\boldsymbol{#1}} \newcommand{\etal}{{et~al.}} \newcommand{\sphere}{\mathbb{S}^2} \newcommand{\modeint}{\mathcal{M}} \newcommand{\azimint}{\mathcal{N}} \newcommand{\ra}{\rightarrow} \newcommand{\mcal}[1]{\mathcal{#1}} \newcommand{\X}{\mathcal{X}} \newcommand{\Y}{\mathcal{Y}} \newcommand{\Z}{\mathcal{Z}} \newcommand{\x}{\mathbf{x}} \newcommand{\y}{\mathbf{y}} \newcommand{\z}{\mathbf{z}} \newcommand{\tr}{\mathrm{tr}} \newcommand{\sgn}{\mathrm{sgn}} \newcommand{\diag}{\mathrm{diag}} \newcommand{\Real}{\mathbb{R}} \newcommand{\sseq}{\subseteq} \newcommand{\ov}[1]{\overline{#1}} \DeclareMathOperator*{\argmax}{arg\,max} \DeclareMathOperator*{\argmin}{arg\,min} \)

14   Abstracting OpenGL


We learned in the past few chapters of the essential components of a GLSL program: vertex shader, fragment shader, vertex attributes, uniform variables, and varying variables. We also learned that rendering a mesh involves multiple steps. We need to create buffers to store vertex data. Then, we need to create a buffer to store the vertex indices that make up primitives. We then use a GLSL program and setup all of its vertex attributes and uniform variables. Finally, we must bind the index buffer and issue a drawing command.

Because of all the above details, we can see that it can be hard to manage those objects, especially in more complex WebGL applications where we might have multiple GLSL programs and multiple meshes. One bad aspect of the way we have written the code so far is that things are not properly encapsulated. This means that things that should be parts of a larger object are not bundled together. For example, when we create a mesh, we need to create two variables: one for the vertex buffer and another for the index buffer. There is nothing to indicate that these two buffers should always be used together, and so the programmer has to keep track of this fact themself.

Another worrying aspect is that the programmer must also keep track of the vertex attributes and uniform variables of all GLSL programs. Because the programmer can name these variables in any way they want, there can be too many names to remember, and this can lead to bugs that are hard to identify. Fortunately, it turns out that GLSL programs in generally tend to use only a small set of vertex attributes. So, we can reduce the cognitive load on the programmer by naming attributes with the same semantics with the same names.

Still, because different GLSL programs serve different purposes, some programs might use one particular vertex attribute (for examples, the vertex color) while others do not. As a result, we need the ability to identify which vertex attributes are present in a GLSL program.

This chapter presents a way to solve the above programs by creating new abstractions (i.e., classes) for GLSL programs and colored meshes. These abstractions are what the author personally use in his personal projects, and they will be use in later chapters of the book. We choose to introduce them here because he thinks he would not be able to manage WebGL's complexity without them. A decent programmer must know the limit of their cognitive capacity and introduce appropriate abstractions to make their life easier.

14.1   An abstraction for GLSL programs

WebGL already has an abstraction for GLSL programs: the WebGLProgram class. However, the author finds that they are not the most convenient to use. In particular, WebGLProgram does not have any information about the vertex attributes or the uniforms of the GLSL program it represents. Moreover, working with vertex attributes and uniforms require issuing at least two WebGL commands. First, we must issue one to get the variable's location. Only then we can issue another command to do what we want with the variable itself.

14.1.1   GlProgram

To deal with the above problem, we introduce a new class called GlProgram that encapsulates a GLSL program together with its vertex attributes and uniforms. The source code for the class is available in the program.js file in the chapter-14/program-01/src directory. Before going into the details of how the class is implemented, let us see what it accomplishes. Using the code we have developed so far in the book, a typical sequence of commands when we use a GLSL program to render some primitives is as follows.

// Step 1: Create the program.
this.program = createGlslProgram(
  this.gl, vertexShaderSource, fragmentShaderSource);

// Step 2: Use programs.
let self = this;
useProgram(this.gl, this.program, () => {
  // Step 3: Set the values of uniforms.
  let centerLocation = self.gl.getUniformLocation(self.program, "center");
  self.gl.uniform2f(centerLocation, centerX, centerY);

  let scaleLocation = self.gl.getUniformLocation(self.program, "scale");
  self.gl.uniform1f(scaleLocation, scale);

  // Step 4: Set up the vertex attributes.
  setupVertexAttribute(
    self.gl, self.program, "vert_position", self.vertexBuffer, 2, 4*5, 0);
  setupVertexAttribute(
    self.gl, self.program, "vert_color", self.vertexBuffer, 3, 4*5, 4*2);

  // Step 5: Call drawElements to draw the primitives.
  drawElements(self.gl, self.indexBuffer, self.gl.TRIANGLES, 6, 0);
});

Using the GlProgram class, we can shorten the code above.

// Step 1: Create the program
this.program = new GlProgram(
  this.gl, vertexShaderSource, fragmentShaderSource);
  
// Step 2: Use the program.
let self = this;
this.program.use(() => {
  // Step 3: Set the values of uniforms.
  self.program.uniform("center")?.set2Float(centerX, centerY);
  self.program.uniform("scale")?.set1Float(scale);

  // Step 4: Set up the vertex attributes.
  self.program.attribute("vert_position")?.setup(
    self.vertexBuffer, 2, 4*5, 0);
  self.program.attribute("vert_color")?.setup(
    self.vertexBuffer, 3, 4*5, 4*2);

  // Step 5: Call drawElements to draw the primitives.
  drawElements(self.gl, self.indexBuffer, self.gl.TRIANGLES, 6, 0);
});

There are cosmetic changes such as how the program field is initialized and the fact that the useProgram function has become a method. The substantive changes, though, are how the uniforms and vertex attributes are accessed. The GlProgram class has the uniform and attribute methods that take a name and returns an object representing a uniform or an attribute, respectively. However, the methods are written so that they return null if there are uniforms or attributes with the given name. This is why we use the optional chaining operator (?.) to invoke the methods (i.e., set2Float, set1Float, and setup) of these objects.

The author argues that the benefits of the new abstraction is not only that the code becomes a little shorter, but also the fact that the vertex attributes and the uniforms are accessed somewhat like fields of the program object. It makes the code easier to read and aligned with the fact that attributes are uniforms are part of a GLSL program.

Another benefits that is not obvious from reading the code is that it eases shader debugging. Debugging any code involves changing the code a little bit and running the changed code to try to discover the root cause of the bug or test a hypothesis. When we debug a GLSL shader, we often rewrite the code so that some vertex attributes or uniforms becomes unused so that the shader becomes simpler and easier to understand. The problem is that, when such an object becomes unused, the GLSL compiler will automatically removes it completely from the GLSL program, and that means that any attempts to access them would cause an error. For example, if we change the code so that it temporarily does not use the center uniform, then the self.gl.getUniformLocation(self.program, "center") command would return null instead of valid WebGLUniformLocation object. Then, the subsequent self.gl.uniform2f(centerLocation, centerX, centerY); would result in an error. It is annoying to have to modify the Javascript code every time we make such changes to the shader. On the other hand, the line

self.program.uniform("center")?.set2Float(centerX, centerY);

automatically skips the setting of the uniform if it does not exist, and this allows us to focus more on the GLSL code rather than modifying the Javascript host code to avoid errors.

14.1.3   Overall structure

Let's take a look at the overall structure of the GlProgram class, whose source code is available in the program.js file of Program 1 of Chapter 14. The class has a constructor and the following three methods:

Here, attribute and uniform are accessors for the vertex attributes and uniforms, which we just mentioned earlier. The use does what its name suggest and serves the same function as the useProgram function we have been utilizing up until this point.

The class has four fields: gl, glObject, attributes, and uniforms. The gl field wholes the WebGLRenderingContext through which all WebGL commands are issued. The glObject field holds the WebGLProgram object that the WebGL API provides when we create a GLSL program. The fields attributes and uniforms are Javascript Map that sends a name to an object representing an attribute or a uniform with that time. The constructor initialize the fields as follows.

export class GlProgram{
    constructor(gl, vertexShaderSource, fragmentShaderSource) {
        this.gl = gl;
        this.glObject = createGlslProgram(
          gl, vertexShaderSource, fragmentShaderSource);

        // Code that fills this.attributes.
        this.attributes = new Map();
           :
           :
        
        // Code that fills this.uniforms.
        this.uniforms = new Map();        
           :
           :
    }
}

Like what we have done before, we create an instance of WebGLProgram with the createGlslProgram function and save the return value in the glObject field. The atttributes and uniforms fields initially hold two new empty Maps. We shall discuss how the maps are filled in the next subsections.

The use method is just a reimplementation of the useProgram function.

use(code) {
  this.gl.useProgram(this.glObject);
  code();
  this.gl.useProgram(null);
}    

With the explanation of the easy parts completed, let us discuss how we model vertex attributes and uniform variables.

14.1.4   Modeling vertex attributes

Our goal here is to create an object that represents each vertex attribute of a GLSL program so that we may have a nicer interface to work with. As a result, we need to extract information about vertex attributes from a WebGLProgram object. The first bit of information we need is how many vertex attributes a program has. This can be objected using the getProgramParameter method of the WebGL context, passing the WebGLProgram instance and the contant ACTIVE_ATTRIBUTES as arguments. The method is used in the constructor of the GlProgram class as follows.

let numAttributes = gl.getProgramParameter(
  this.glObject, gl.ACTIVE_ATTRIBUTES);

WebGL refers to the vertex atrributes with integer indices from 0 to numAttributes-1, and it provides us a way to query information about a vertex attribute given its index. We shall use this mechanism inside the constructor of a GlAttribute object, which we create to model a vertex attribute. Before discussing the details of the class, let us see how the GlAttribute objects are instantiated.

this.attributes = new Map();
for (let index = 0; index < numAttributes; index++) {
  let attribute = new GlAttribute(gl, this, index);
  this.attributes.set(attribute.name, attribute);
}

Here, we create field called attributes that is a Javascript Map which allows us to refer to vertex attributes by their names. We then loop through the indices of the vertex attributs and create an instance of GlAttribute for each of the index. The created will have a field called name, which we can use when we add the object to the map. Once the loop has finished running, we can use the attributes field to implement the attribute method of the GlProgram class that we discuss in the last section as follows.

attribute(name) {
  if (this.attributes.has(name)) {
    return this.attributes.get(name);
  } else {
    return null;
  }
}

Now, let us discuss the implementation of the GlAttribute object itself. The following is the its constructor.

export class GlAttribute {
  constructor(gl, program, index) {
    this.gl = gl;
    this.program = program;
    this.index = index;

    let info = gl.getActiveAttrib(program.glObject, index);
    this.name = info.name;
    this.size = info.size;
    this.type = info.type;
    this.location = gl.getAttribLocation(program.glObject, this.name);
    this.enabled = false;
  }

  :
  :
}

The constructor accepts three arguments: the WebGL context, the GlProgram object to which this GlAttribute object belongs, and lastly the index of the vertex attribute. The first three lines of the constructor saves the arguments as fields. The rest of the constructor extracts and saves information about the vertex attribute itself.

First, we call the getAttiveAttrib method of the WebGL context, passing the WebGLProgram instance stored in the GlProgram object and the index of the vertex attribute as arguments. The method would return the WebGLActiveInfo, which has three fields.

We then save these fields of the WebGLActiveInfo as fields of the GlAttribute instance.

The next step is to retrieve the location of the vertex attribute. We have done this before in most programs in the previous chapters. However, this time, we do not specify the name of the vertex attribute ourselves but use the name we queried using the glActiveInfo method.

Lastly, we initialize the enabled field to false. This field keeps track whether the vertex attribute has been enabled using the enableVertexAttribArray method or not.

The class has two methods. The first is setEnabled, which we will not use directly most of the time.

setEnabled(enabled) {
    if (enabled) {
        this.gl.enableVertexAttribArray(this.location);
        this.enabled = true;
    } else {
        this.gl.glDisableVertexAttribArray(this.location);
        this.enabled = false;
    }
}

The argument enabled is a boolean that indicates whether the user wants to enable of disable the attribute. So, to enable, we call enable(true), and, to disable, we call enable(false). The other method is the setup method, which replaces the setupVertexAttribute function we have been using so far.

setup(buffer, size, stride, offset, type=null, normalized=false) {
  this.setEnabled(true);
  type = type || this.gl.FLOAT;
  let self = this;
  bindBuffer(this.gl, this.gl.ARRAY_BUFFER, buffer, () => {
    self.gl.vertexAttribPointer(
      self.location, size, type, normalized, stride, offset);
  });
}

With the setup method in place, we can succinctly setup vertex attributes as like we discussed in the last section.

self.program.attribute("vert_position")?.setup(
  self.vertexBuffer, 2, 4*5, 0);
self.program.attribute("vert_color")?.setup(
  self.vertexBuffer, 3, 4*5, 4*2);

14.1.5   Modeling uniform variables

The next thing to do is to model uniform variables in the same way we did with vertex attributes. The process to do this starts in the constructor of the GlProgram class that we showed, in an incomplete from, in the last section. The following is the code that fills the uniforms map that associates uniforms with their names.

let numUniforms = gl.getProgramParameter(this.glObject, gl.ACTIVE_UNIFORMS);
this.uniforms = new Map();
for (let index = 0; index < numUniforms; index++) {
  let uniform = new GlUniform(gl, this, index);
  this.uniforms.set(uniform.name, uniform);
}

What we did here is similar to what we did in the last section. First, we find out how many uniform variables the program has, and this is done by calling the getProgramParameter method with the ACTIVE_UNIFORMS constant.

let numUniforms = gl.getProgramParameter(this.glObject, gl.ACTIVE_UNIFORMS);

Similar to vertex attributes, uniforms are indexed from $0$ to the number of uniforms minus one. Again, we iterate through the indices with the for loop and create a GlUniform object to represent the uniform with a given index. We then call the set method of the Map class to assiociate the newly created GlUniform object with the uniform's name.

Let us now look at the GlUniform class, which is also located in the program.js file. First, let us look at the constructor.

export class GlUniform {
  constructor(gl, program, index) {
    this.gl = gl;
    this.program = program;
    this.index = index;
    
    let info = gl.getActiveUniform(program.glObject, index);
    this.name = info.name;
    this.type = info.type;
    this.size = info.size;
    this.location = gl.getUniformLocation(program.glObject, this.name);
  }

  :
  :
}

Much like the constructor of GlAttribute, GlUniform's is given the WebGL context, a GlProgram instance, and the index of the uniform. After saving the input arguments as fields, we proceed to get information about the uniform by calling the getActiveUniform of the WebGL context, which is the uniform counterpart of the glAtiveAttrib method. The two method receives the same arguments (a WebGLProgram object and an index), and it returns the same thing (a WebGLActiveInfo object). We then proceed to save the information in the return value as fields. The last line of the constructor queries the location of the uniform using the name we fetched with getActiveUniorm method. Because a uniform does not require enabling like a vertex attribute, we do not need the enabled field like the GlUniform class.

The GlUniform class has more methods than the GlAttribute class. This is because there are WebGL context methods such as uniform1f, uniform2f, and so on to wrap. In this chapter, we wrap 8 methods that are used to set vector types with float or integer components. We list some of the wrapping methods below and refer the reader to the code repository for the full listing.

export class GlUniform {
  :
  :

  set1Int(x) {
    this.gl.uniform1i(this.location, x);
  }

  set2Int(x, y) {
    this.gl.uniform2i(this.location, x, y);
  }
    
  :
  :

  set1Float(x) {
    this.gl.uniform1f(this.location, x);
  }

  set2Float(x, y) {
    this.gl.uniform2f(this.location, x, y);
  }
    
  :
  :
}

With the above methods, we can assign uniforms of a GLSL program as follows.

  self.program.uniform("center")?.set2Float(centerX, centerY);
self.program.uniform("scale")?.set1Float(scale);

14.1.6   Program 1

Program 1 from the code repository contains a demonstration of how the abstractions in the last few sections are used. As we can see from the screenshots in Figure 14.1, the screen shows a square painted with a gradient of colors. The user can manipulate the position and the size of the square through through the sliders below the screen. This makes it quite similar to Program 2 from the last chapter in terms of what it shows and Program 4 (also from the same chapter) in terms of what the user can do with the UI.

Figure 14.1 Screenshots of Program 1.

However, Program 1 is different from all the programs we have seen so far because it displays information about the GLSL program being used to update the screen. In particular, there are two tables listing the vertex attributes and uniforms that the GLSL program has. We can show such information now precisely because we took time to create objects to represent these variables.

If the reader is curious, they are welcome to read the index.js file to see how the shaders are implemented (not very different from the shaders from Chapter 13) and how we use the created GlAttribute and GlUniform objects to populate the tables (rather tedious and not very instructive). The main point of this section is refactoring: introducing a new way to do the same thing we did before so that we have a better foundation for more complex programs later.

14.2   An abstraction for colored meshes


<< Contents