ShaderProgram

How to 3D

Chapter 1: Points and Lines

ShaderProgram

Two programs are needed to draw an object: a vertex shader that positions each vertex in the framebuffer and a fragment shader that colors the pixels between vertices. These shaders are written in the OpenGL Shading Language (GLSL).

Here's a very simple vertex shader that just echoes out the incoming position attribute:

in vec3 position;

void main() {
  gl_Position = vec4(position, 1.0);
}
in vec3 position;

void main() {
  gl_Position = vec4(position, 1.0);
}

Some graphics developers put shader code directly in a string embedded in the program. That's compact, but we want syntax highlighting and line numbers. Instead, put the vertex shader code in its own file named flat-vertex.glsl in the apps/hello-cornflower directory.

Any top-level variable declaration marked in in a GLSL shader corresponds to an incoming vertex property that was pulled from a vertex buffer. This particular vertex shader only pulls in the property that we named position. Its type is vec3, a vector of three floats. It ignores the color property.

The very least that a vertex shader must do is assign a vec4 to the gl_Position variable, which tells the graphics card where on the framebuffer the vertex lands. As we develop more advanced renderers, we'll do more than just echo position as we do here. We'll also discover why gl_Position has a fourth element.

The job of the fragment shader is to emit a color for each pixel. This fragment shader does so by writing the color blue to the out variable fragmentColor:

out vec4 fragmentColor;

void main() {
  fragmentColor = vec4(0.0, 0.0, 1.0, 1.0);
}
out vec4 fragmentColor;

void main() {
  fragmentColor = vec4(0.0, 0.0, 1.0, 1.0);
}

Put this code in a file named flat-fragment.glsl in the apps/hello-cornflower directory.

One of several things will happen to the color written to fragmentColor. If the fragment is blocked or occluded by objects nearer to the viewer, the fragment will be discarded. If it's not blocked, the color will be written to the framebuffer. If the scene contains semi-transparent surfaces, the color will be blended with a color written earlier.

The vertex and fragment shaders must be compiled down to the GPU's machine code and linked together to form a complete shader program. The machinery to do this is also quite low-level, so we abstract it away with another class. Create the file lib/shader-program.ts in your hello-cornflower project and copy in this code:

export class ShaderProgram {
  vertexShader: WebGLShader;
  fragmentShader: WebGLShader;
  program: WebGLProgram;
  uniforms: {[name: string]: WebGLUniformLocation};
  isBound: boolean;

  constructor(vertexSource: string, fragmentSource: string, version: number = 300, precision = 'mediump') {
    this.isBound = false;

    // Compile.
    this.vertexShader = this.compileSource(gl.VERTEX_SHADER, `#version ${version} es\n${vertexSource}`);
    this.fragmentShader = this.compileSource(gl.FRAGMENT_SHADER, `#version ${version} es\nprecision ${precision} float;\n${fragmentSource}`);

    // Link.
    this.program = gl.createProgram()!;
    gl.attachShader(this.program, this.vertexShader);
    gl.attachShader(this.program, this.fragmentShader);
    gl.linkProgram(this.program);

    let isOkay = gl.getProgramParameter(this.program, gl.LINK_STATUS);
    if (!isOkay) {
      let message = gl.getProgramInfoLog(this.program);
      gl.deleteProgram(this.program);
      throw message;
    }

    // Query uniforms.
    this.uniforms = {};
    let nuniforms = gl.getProgramParameter(this.program, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < nuniforms; ++i) {
      let uniform = gl.getActiveUniform(this.program, i)!;
      let location = gl.getUniformLocation(this.program, uniform.name)!;
      this.uniforms[uniform.name] = location;

      // If uniform is an array, find locations of other elements.
      for (let elementIndex = 1; elementIndex < uniform.size; ++elementIndex) {
        const elementName = uniform.name.replace(/\[0\]$/, `[${elementIndex}]`);
        location = gl.getUniformLocation(this.program, elementName)!;
        if (location) {
          this.uniforms[elementName] = location;
        }
      }
    }

    this.unbind();
  }

  destroy() {
    gl.deleteShader(this.vertexShader);
    gl.deleteShader(this.fragmentShader);
    gl.deleteProgram(this.program);
  }

  compileErrorReport(message: string, type: number, source: string) {
    let report = `I found errors in a ${type === gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader.\n`;

    let matches = message.matchAll(/^ERROR: (\d+):(\d+): (.*)$/gm);
    for (let match of matches) {
      let line = parseInt(match[2]) - 1;
      let NEIGHBOR_COUNT = 2;
      let lines = source.split(/\r?\n/);
      let startLine = Math.max(0, line - NEIGHBOR_COUNT);
      let endLine = Math.min(lines.length - 1, line + NEIGHBOR_COUNT);
      let lineIndexWidth = (endLine + 1).toString().length;
      report += `\nError on line ${line + 1}: ${match[3]}\n\n`;
      for (let i = startLine; i <= endLine; ++i) {
        if (i === line) {
          report += `! ${(i + 1).toString().padStart(lineIndexWidth, ' ')}   ${lines[i]}\n`;
        } else {
          report += `  ${(i + 1).toString().padStart(lineIndexWidth, ' ')}   ${lines[i]}\n`;
        }
      }
    }

    return report;
  }

  compileSource(type: number, source: string) {
    let shader = gl.createShader(type)!;
    gl.shaderSource(shader, source);
    gl.compileShader(shader);

    let isOkay = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
    if (!isOkay) {
      let message = gl.getShaderInfoLog(shader)!;
      gl.deleteShader(shader);
      throw new ShaderCompilationError(this.compileErrorReport(message, type, source));
    }

    return shader;
  }

  getAttributeLocation(name: string) {
    return gl.getAttribLocation(this.program, name);
  }

  bind() {
    gl.useProgram(this.program);
    this.isBound = true;
  }

  unbind() {
    gl.useProgram(null);
    this.isBound = false;
  }

  assertUniform(name: string) {
    if (!this.uniforms.hasOwnProperty(name)) {
      console.warn(`${name} isn't a valid uniform.`);
    }
  }

  setUniform1i(name: string, value: number) {
    this.assertUniform(name);
    gl.uniform1i(this.uniforms[name], value);
  }

  setUniform1f(name: string, value: number) {
    this.assertUniform(name);
    gl.uniform1f(this.uniforms[name], value);
  }

  setUniform2f(name: string, a: number, b: number) {
    this.assertUniform(name);
    gl.uniform2f(this.uniforms[name], a, b);
  }

  setUniform3f(name: string, a: number, b: number, c: number) {
    this.assertUniform(name);
    gl.uniform3f(this.uniforms[name], a, b, c);
  }

  setUniformMatrix4fv(name: string, elements: Float32Array) {
    this.assertUniform(name);
    gl.uniformMatrix4fv(this.uniforms[name], false, elements);
  }
}

class ShaderCompilationError extends Error {
  constructor(message: string) {
    super(message);
  }
}
export class ShaderProgram {
  vertexShader: WebGLShader;
  fragmentShader: WebGLShader;
  program: WebGLProgram;
  uniforms: {[name: string]: WebGLUniformLocation};
  isBound: boolean;

  constructor(vertexSource: string, fragmentSource: string, version: number = 300, precision = 'mediump') {
    this.isBound = false;

    // Compile.
    this.vertexShader = this.compileSource(gl.VERTEX_SHADER, `#version ${version} es\n${vertexSource}`);
    this.fragmentShader = this.compileSource(gl.FRAGMENT_SHADER, `#version ${version} es\nprecision ${precision} float;\n${fragmentSource}`);

    // Link.
    this.program = gl.createProgram()!;
    gl.attachShader(this.program, this.vertexShader);
    gl.attachShader(this.program, this.fragmentShader);
    gl.linkProgram(this.program);

    let isOkay = gl.getProgramParameter(this.program, gl.LINK_STATUS);
    if (!isOkay) {
      let message = gl.getProgramInfoLog(this.program);
      gl.deleteProgram(this.program);
      throw message;
    }

    // Query uniforms.
    this.uniforms = {};
    let nuniforms = gl.getProgramParameter(this.program, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < nuniforms; ++i) {
      let uniform = gl.getActiveUniform(this.program, i)!;
      let location = gl.getUniformLocation(this.program, uniform.name)!;
      this.uniforms[uniform.name] = location;

      // If uniform is an array, find locations of other elements.
      for (let elementIndex = 1; elementIndex < uniform.size; ++elementIndex) {
        const elementName = uniform.name.replace(/\[0\]$/, `[${elementIndex}]`);
        location = gl.getUniformLocation(this.program, elementName)!;
        if (location) {
          this.uniforms[elementName] = location;
        }
      }
    }

    this.unbind();
  }

  destroy() {
    gl.deleteShader(this.vertexShader);
    gl.deleteShader(this.fragmentShader);
    gl.deleteProgram(this.program);
  }

  compileErrorReport(message: string, type: number, source: string) {
    let report = `I found errors in a ${type === gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader.\n`;

    let matches = message.matchAll(/^ERROR: (\d+):(\d+): (.*)$/gm);
    for (let match of matches) {
      let line = parseInt(match[2]) - 1;
      let NEIGHBOR_COUNT = 2;
      let lines = source.split(/\r?\n/);
      let startLine = Math.max(0, line - NEIGHBOR_COUNT);
      let endLine = Math.min(lines.length - 1, line + NEIGHBOR_COUNT);
      let lineIndexWidth = (endLine + 1).toString().length;
      report += `\nError on line ${line + 1}: ${match[3]}\n\n`;
      for (let i = startLine; i <= endLine; ++i) {
        if (i === line) {
          report += `! ${(i + 1).toString().padStart(lineIndexWidth, ' ')}   ${lines[i]}\n`;
        } else {
          report += `  ${(i + 1).toString().padStart(lineIndexWidth, ' ')}   ${lines[i]}\n`;
        }
      }
    }

    return report;
  }

  compileSource(type: number, source: string) {
    let shader = gl.createShader(type)!;
    gl.shaderSource(shader, source);
    gl.compileShader(shader);

    let isOkay = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
    if (!isOkay) {
      let message = gl.getShaderInfoLog(shader)!;
      gl.deleteShader(shader);
      throw new ShaderCompilationError(this.compileErrorReport(message, type, source));
    }

    return shader;
  }

  getAttributeLocation(name: string) {
    return gl.getAttribLocation(this.program, name);
  }

  bind() {
    gl.useProgram(this.program);
    this.isBound = true;
  }

  unbind() {
    gl.useProgram(null);
    this.isBound = false;
  }

  assertUniform(name: string) {
    if (!this.uniforms.hasOwnProperty(name)) {
      console.warn(`${name} isn't a valid uniform.`);
    }
  }

  setUniform1i(name: string, value: number) {
    this.assertUniform(name);
    gl.uniform1i(this.uniforms[name], value);
  }

  setUniform1f(name: string, value: number) {
    this.assertUniform(name);
    gl.uniform1f(this.uniforms[name], value);
  }

  setUniform2f(name: string, a: number, b: number) {
    this.assertUniform(name);
    gl.uniform2f(this.uniforms[name], a, b);
  }

  setUniform3f(name: string, a: number, b: number, c: number) {
    this.assertUniform(name);
    gl.uniform3f(this.uniforms[name], a, b, c);
  }

  setUniformMatrix4fv(name: string, elements: Float32Array) {
    this.assertUniform(name);
    gl.uniformMatrix4fv(this.uniforms[name], false, elements);
  }
}

class ShaderCompilationError extends Error {
  constructor(message: string) {
    super(message);
  }
}

The constructor of this class expects the vertex and fragment shader source as strings, but we have them in separate files. We need to load the files in when the page loads using JavaScript's fetch function. Because fetching could be slow, the function is asynchronous. Parsing the HTTP response is also asynchronous. So we write this wrapper function to manage the pipeline:

export async function fetchText(url: string) {
  const response = await fetch(url);
  const text = await response.text();
  return text;
}
export async function fetchText(url: string) {
  const response = await fetch(url);
  const text = await response.text();
  return text;
}

Create the file lib/web-utilities.ts and paste this code in. We'll add more functions to this file throughout the semester.

Back in initialize in main.ts, load, compile, and link the shaders together by constructing an instance of ShaderProgram, like this:

const vertexSource = await fetchText('flat-vertex.glsl');
const fragmentSource = await fetchText('flat-fragment.glsl');
shaderProgram = new ShaderProgram(vertexSource, fragmentSource);
const vertexSource = await fetchText('flat-vertex.glsl');
const fragmentSource = await fetchText('flat-fragment.glsl');
shaderProgram = new ShaderProgram(vertexSource, fragmentSource);

Note that the shaderProgram variable lacks a declaration like let or const. Other functions will need to access it, so it needs to have a bigger scope. Declare this variable at the top of main.ts as a global:

JavaScript
let shaderProgram: ShaderProgram;
let shaderProgram: ShaderProgram;

You still won't see the line segment rendered. We need to marry the set of VertexAttributes to the ShaderProgram together and then issue a draw call.

← Vertex AttributesVertex Array →