\( \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);

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

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

14.1.5   Modeling uniform variables

14.1.6   Program 1

14.2   An abstraction for colored meshes


<< Contents