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.
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.
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.
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:
use
,attribute
, anduniform
.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 Map
s. 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.