DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on • Updated on

100 Languages Speedrun: Episode 41: WebGL Shader Language

WebGL lets websites use GPU. To simplify things a lot, the way GPUs work is:

  • you send them some scene description, mostly a lot of triangles and their associated data
  • GPU runs "vertex shader" for every corner of the triangle to determine where it should be drawn
  • for every triangle, GPU figures out which pixels it covers, and which triangle is closest to camera at every point
  • then GPU runs "fragment shader" (also known as "pixel shader") for every pixel of every triangle that gets drawn - that program determines what color to draw the pixel, and handles textures, lightning and so on

Why GPUs

The reason GPUs are so stupidly fast at what they do is that they run the same program thousands or millions of times. So you can imagine that GPU contains hundreds or thousands of mini-CPUs that each are quite weak, and they can only all run the same program at any time, but well, there's a lot of them.

For regular programs, GPUs would be too slow to do anything, but beyond graphics there's a few other applications where you need to do the same thing millions of times, and GPUs are the perfect solution. Crypto mining and neural networks being the most obvious.

WebGL Shader Language

What we're going to do is a pure shader language solution. There will be no real geometry and no real vertex shader - just one big square covering the whole canvas. Or to be precise, two triangles, as GPUs don't like any shapes that aren't triangles. Everything will be done in the fragment shader.

WebGL is very boilerplate heavy, and normally you'd use it with some framework that deals with all that low level nonsense. I'll show the boilerplate just once, and without much explaining.

Boilerplate

The only thing we'll be dealing with is fragmentShaderSource. Just treat the rest as irrelevant boilerplate for now:

<style>
  body {
    margin: 0;
    height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
  }
</style>

<canvas height="800" width="800"></canvas>

<script>
  let canvas = document.querySelector("canvas")
  let gl = canvas.getContext("webgl")
  let vertexShaderSource = `
  attribute vec2 points;
  void main() {
    gl_Position = vec4(points, 0.0, 1.0);
  }`

  let fragmentShaderSource = `
  void main() {
    mediump vec2 pos = gl_FragCoord.xy / vec2(800, 800);
    gl_FragColor = vec4(0, pos.x, pos.y, 1.0);
  }`

  let program = gl.createProgram()

  // create a new vertex shader and a fragment shader
  let vertexShader = gl.createShader(gl.VERTEX_SHADER)
  let fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)

  // specify the source code for the shaders using those strings
  gl.shaderSource(vertexShader, vertexShaderSource)
  gl.shaderSource(fragmentShader, fragmentShaderSource)

  // compile the shaders
  gl.compileShader(vertexShader)
  gl.compileShader(fragmentShader)

  // attach the two shaders to the program
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  gl.useProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
  }

  let points = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1, 1, -1, -1, 1])
  let buffer = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW)
  let pointsLocation = gl.getAttribLocation(program, "points")
  gl.vertexAttribPointer(pointsLocation, 2, gl.FLOAT, false, 0, 0)
  gl.enableVertexAttribArray(pointsLocation)
  gl.drawArrays(gl.TRIANGLES, 0, 6)
</script>
Enter fullscreen mode Exit fullscreen mode

Hello, World!

Let's go through the fragment shader source:

  void main() {
    mediump vec2 pos = gl_FragCoord.xy / vec2(800, 800);
    gl_FragColor = vec4(0, pos.x, pos.y, 1.0);
  }
Enter fullscreen mode Exit fullscreen mode

gl_FragCoord is the input - its position on the screen. Weirdly if we set canvas size with <canvas height="800" width="800"></canvas>, then this works, but if we set canvas size with CSS, WebGL will think the canvas is 300x150.

gl_FragCoord has 4 coordinates: x, y showing position on the canvas (annoyingly bottom left as 0, 0 instead of top left), z is how deep the fragment is - which doesn't matter as we don't have any overlapping triangles, and w isn't really relevant for us.

gl_FragColor is the color, also a 4 vector - with three components being RGB, and the last one being opacity. They're on scale of 0 to 1, unlike CSS 0 to 255.

mediump vec2 pos declares local variable - two element vector, of medium precision. In WebGL you need to give everything precision, that's not even true in traditional OpenGL.

gl_FragCoord.xy / vec2(800, 800) - it takes xy part of the gl_FragCoord vector and divides them by 800. It's same as vec2(gl_FragCoord.x / 800, gl_FragCoord.y / 800). WebGL uses a lot of such vector operations so we better get used to them.

This generates the following image:

Hello

As you can see it's greener to the right, and bluer to the top. Red is zero, opacity is max.

Checkerboard

This checkerboard is not very pretty, but the goal is to show that we have cell number in cell and position within cell with t.

  void main() {
    mediump vec2 pos = gl_FragCoord.xy / vec2(80, 80);
    mediump vec2 cell = floor(pos);
    mediump vec2 t = fract(pos);
    mediump float u = fract((cell.x + cell.y) / 2.0);
    if (u == 0.0) {
      gl_FragColor = vec4(t.y, 0, t.x, 1.0);
    } else {
      gl_FragColor = vec4(0, t.x, t.y, 1.0);
    }
  }
Enter fullscreen mode Exit fullscreen mode

This generates the following image:

Checkerboard

FizzBuzz Board

The next step towards doing a working FizzBuzz is to treat these cells as numbers 1 to 100 (top left being 1, then going in natural writing order).

  • Fizz is red
  • Buzz is green
  • FizzBuzz is blue
  • Numbers are shades of grey, proportional from 1 to 100
  // a % b returns "integer modulus operator supported in GLSL ES 3.00 and above only"
  // so we do it old school
  bool divisible(int a, int b) {
    return a - (a / b) * b == 0;
  }

  void main() {
    mediump vec2 pos = gl_FragCoord.xy / vec2(80, 80);
    mediump vec2 cell = floor(pos);
    int n = int(cell.x) + (9 - int(cell.y)) * 10 + 1;
    mediump float nf = float(n);

    if (divisible(n, 15)) {
      gl_FragColor = vec4(0.5, 0.5, 1.0, 1.0);
    } else if (divisible(n, 5)) {
      gl_FragColor = vec4(0.5, 1.0, 0.5, 1.0);
    } else if (divisible(n, 3)) {
      gl_FragColor = vec4(1.0, 0.5, 0.5, 1.0);
    } else {
      gl_FragColor = vec4(nf/100.0, nf/100.0, nf/100.0, 1.0);
    }
  }
Enter fullscreen mode Exit fullscreen mode

FizzBuzz Board

We could also switch the script to version it wants by starting it with #version 300 es, but that would require some more changes, so let's just continue with what we started.

On normal CPU we wouldn't need to switch to integers as float division is exact if it's at all possible. 45.0 / 15.0 is exactly 3.0, no ifs no buts about it. On GPUs (at least with mediump), not so much. We'd get something close to 3.0, but that would make the whole algorithm quite annoying. That's another way how GPUs win the race - for drawing pixels you don't need this full accuracy.

FizzBuzz Digits

We're definitely getting there, the next step would be to display each digit separately. So any digit field would get split in two - left one would be the first digit, right one would be the second digit. We're doing 1-100, but 100 is a Buzz, so we never need three digits. We should also skip leading digit if it's a zero, but we have only so many colors.

  bool divisible(int a, int b) {
    return a - (a / b) * b == 0;
  }

  void main() {
    mediump vec2 pos = gl_FragCoord.xy / vec2(80, 80);
    mediump vec2 cell = floor(pos);
    int n = int(cell.x) + (9 - int(cell.y)) * 10 + 1;
    bool right_half = fract(pos.x) > 0.5;
    int tens = n / 10;
    int ones = n - tens * 10;

    if (divisible(n, 15)) {
      gl_FragColor = vec4(0.5, 0.5, 1.0, 1.0);
    } else if (divisible(n, 5)) {
      gl_FragColor = vec4(0.5, 1.0, 0.5, 1.0);
    } else if (divisible(n, 3)) {
      gl_FragColor = vec4(1.0, 0.5, 0.5, 1.0);
    } else if (right_half) {
      gl_FragColor = vec4(float(ones)/10.0, float(ones)/10.0, float(ones)/10.0, 1.0);
    } else {
      gl_FragColor = vec4(float(tens)/10.0, float(tens)/10.0, float(tens)/10.0, 1.0);
    }
  }
Enter fullscreen mode Exit fullscreen mode

FizzBuzz Digits

FizzBuzz

At this point we can take it two ways - either have all the complex code to render each character and digit like with Logo episode. Or use a texture. I think texture solution would be more in line with what WebGL is all about, even if it means more boilerplate.

So first, here's the texture:

Texture

And here's the whole program, with the updated boilerplate:

<style>
  body {
    margin: 0;
    height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
  }
</style>

<canvas height="800" width="800"></canvas>

<script>
let img = new Image()
img.crossOrigin = ""
img.src = `./texture.png`
img.onload = () => {
  startWebGL()
}

let startWebGL = () => {
  let canvas = document.querySelector("canvas")
  let gl = canvas.getContext("webgl")
  let vertexShaderSource = `
  attribute vec2 points;
  void main() {
    gl_Position = vec4(points, 0.0, 1.0);
  }`

  let fragmentShaderSource = `
  uniform sampler2D sampler;

  bool divisible(int a, int b) {
    return a - (a / b) * b == 0;
  }

  void main() {
    mediump vec2 pos = gl_FragCoord.xy / vec2(80, 80);
    mediump vec2 cell = floor(pos);
    mediump float px = fract(pos.x);
    mediump float py = fract(pos.y);
    int n = int(cell.x) + (9 - int(cell.y)) * 10 + 1;
    bool right_half = px > 0.5;
    int tens = n / 10;
    int ones = n - tens * 10;
    mediump float cx, cy;

    cx = gl_FragCoord.x / 800.0;

    if (divisible(n, 15)) {
      cx = 15.0;
    } else if (divisible(n, 5)) {
      cx = 13.0;
    } else if (divisible(n, 3)) {
      cx = 11.0;
    } else if (right_half) {
      cx = float(ones);
    } else if (tens == 0) {
      cx = float(tens);
    } else {
      cx = float(tens) + 1.0;
    }

    cy = 1.0-fract(pos.y);

    gl_FragColor = texture2D(sampler, vec2((cx + px*2.0)/17.0, cy));
  }`

  let program = gl.createProgram()

  // create a new vertex shader and a fragment shader
  let vertexShader = gl.createShader(gl.VERTEX_SHADER)
  let fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)

  // specify the source code for the shaders using those strings
  gl.shaderSource(vertexShader, vertexShaderSource)
  gl.shaderSource(fragmentShader, fragmentShaderSource)

  // compile the shaders
  gl.compileShader(vertexShader)
  gl.compileShader(fragmentShader)
  console.error(gl.getShaderInfoLog(fragmentShader))

  // attach the two shaders to the program
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  gl.useProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
  }

  let points = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1, 1, -1, -1, 1])
  let buffer = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW)
  let pointsLocation = gl.getAttribLocation(program, "points")
  gl.vertexAttribPointer(pointsLocation, 2, gl.FLOAT, false, 0, 0)
  gl.enableVertexAttribArray(pointsLocation)

  let texture = gl.createTexture()
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  gl.activeTexture(gl.TEXTURE0)
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.uniform1i(gl.getUniformLocation(program, "sampler"), 0)

  gl.drawArrays(gl.TRIANGLES, 0, 6)
}
</script>
Enter fullscreen mode Exit fullscreen mode

I'd recommend ignoring all the stuff related to loading image into a texture, and just focusing on fragmentShaderSource which is fairly nice. Image regardless of its size is treated as 0.0 to 1.0 square. So our shader needs to calculate how each pixel corresponds to some point on the image.

FizzBuzz

Should you use WebGL?

WebGL provides functionality that's not really achievable in any other way, like high performance graphics on phones, but it's extremely low level and just painful to write directly, so I don't recommend that.

Fortunately there's a lot of frameworks built on top of WebGL, from classic three.js to Unity to the new hotness Svelte Cubed.

I definitely recommend picking one of these frameworks instead. And it's actually easier to write WebGL Shader Language shaders with them than with plain WebGL, as they deal with a lot of boilerplate around the shaders for you.

Code

All code examples for the series will be in this repository.

Code for the WebGL Shader Language episode is available here.

Discussion (0)