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>
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);
}
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:
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);
}
}
This generates the following image:
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);
}
}
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);
}
}
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:
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>
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.
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.
Top comments (0)