Day 2. Simple shader and triangle
This is a series of blog posts related to WebGL. New post will be available every day
Join mailing list to get new posts right to your inbox
Built with
Yesterday we've learned what WebGL does โ calculates each pixel color inside renderable area. But how does it actually do that?
WebGL is an API which works with your GPU to render stuff. While JavaScript is executed by v8 on a CPU, GPU can't execute JavaScript, but it is still programmable
One of the languages GPU "understands" is GLSL, so we'll famialarize ourselves not only with WebGL API, but also with this new language.
GLSL is a C like programming language, so it is easy to learn and write for JavaScript developers.
But where do we write glsl code? How to pass it to GPU in order to execute?
Let's write some code
Let's create a new js file and get a reference to WebGL rendering context
๐ index.html
</head>
<body>
<canvas></canvas>
- <script src="./src/canvas2d.js"></script>
+ <script src="./src/webgl-hello-world.js"></script>
</body>
</html>
๐ src/webgl-hello-world.js
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
The program executable by GPU is created by method of WebGL rendering context
๐ src/webgl-hello-world.js
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
+
+ const program = gl.createProgram();
GPU program consists of two "functions"
These functions are called shaders
WebGL supports several types of shaders
In this example we'll work with vertex
and fragment
shaders.
Both could be created with createShader
method
๐ src/webgl-hello-world.js
const gl = canvas.getContext('webgl');
const program = gl.createProgram();
+
+ const vertexShader = gl.createShader(gl.VERTEX_SHADER);
+ const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
Now let's write the simpliest possible shader
๐ src/webgl-hello-world.js
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
+
+ const vShaderSource = `
+ void main() {
+
+ }
+ `;
This should look pretty familiar to those who has some C/C++ experience
Unlike C or C++ main
doesn't return anyhting, it assignes a value to a global variable gl_Position
instead
๐ src/webgl-hello-world.js
const vShaderSource = `
void main() {
-
+ gl_Position = vec4(0, 0, 0, 1);
}
`;
Now let's take a closer look to what is being assigned.
There is a bunch of functions available in shaders.
vec4
function creates a vector of 4 components.
gl_Position = vec4(0, 0, 0, 1);
Looks weird.. We live in 3-dimensional world, what on earth is the 4th component? Is it time
? ๐
Not really
It turns out that this addition allows for lots of nice techniques for manipulating 3D data.
A three dimensional point is defined in a typical Cartesian coordinate system. The added 4th dimension changes this point into a homogeneous coordinate. It still represents a point in 3D space and it can easily be demonstrated how to construct this type of coordinate through a pair of simple functions.
For now we can just ingore the 4th component and set it to 1.0
just because
Alright, we have a shader variable, shader source in another variable. How do we connect these two? With
๐ src/webgl-hello-world.js
gl_Position = vec4(0, 0, 0, 1);
}
`;
+
+ gl.shaderSource(vertexShader, vShaderSource);
GLSL shader should be compiled in order to be executed
๐ src/webgl-hello-world.js
`;
gl.shaderSource(vertexShader, vShaderSource);
+ gl.compileShader(vertexShader);
Compilation result could be retreived by . This method returns a "compiler" output. If it is an empty string โ everyhting is good
๐ src/webgl-hello-world.js
gl.shaderSource(vertexShader, vShaderSource);
gl.compileShader(vertexShader);
+
+ console.log(gl.getShaderInfoLog(vertexShader));
We'll need to do the same with fragment shader, so let's implement a helper function which we'll use for fragment shader as well
๐ src/webgl-hello-world.js
}
`;
- gl.shaderSource(vertexShader, vShaderSource);
- gl.compileShader(vertexShader);
+ function compileShader(shader, source) {
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
- console.log(gl.getShaderInfoLog(vertexShader));
+ const log = gl.getShaderInfoLog(shader);
+
+ if (log) {
+ throw new Error(log);
+ }
+ }
+
+ compileShader(vertexShader, vShaderSource);
How does the simpliest fragment shader looks like? Exactly the same
๐ src/webgl-hello-world.js
}
`;
+ const fShaderSource = `
+ void main() {
+
+ }
+ `;
+
function compileShader(shader, source) {
gl.shaderSource(shader, source);
gl.compileShader(shader);
Computation result of a fragment shader is a color, which is also a vector of 4 components (r, g, b, a). Unlike CSS, values are in range of [0..1]
instead of [0..255]
. Fragment shader computation result should be assigned to the variable gl_FragColor
๐ src/webgl-hello-world.js
const fShaderSource = `
void main() {
-
+ gl_FragColor = vec4(1, 0, 0, 1);
}
`;
}
compileShader(vertexShader, vShaderSource);
+ compileShader(fragmentShader, fShaderSource);
Now we should connect program
with our shaders
๐ src/webgl-hello-world.js
compileShader(vertexShader, vShaderSource);
compileShader(fragmentShader, fShaderSource);
+
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
Next step โ link program. This phase is required to verify if vertex and fragment shaders are compatible with each other (we'll get to more details later)
๐ src/webgl-hello-world.js
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
+
+ gl.linkProgram(program);
Our application could have several programs, so we should tell gpu which program we want to use before issuing a draw call
๐ src/webgl-hello-world.js
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
+
+ gl.useProgram(program);
Ok, we're ready to draw something
๐ src/webgl-hello-world.js
gl.linkProgram(program);
gl.useProgram(program);
+
+ gl.drawArrays();
WebGL can render several types of "primitives"
- Points
- Lines
- Triangels
We should pass a primitive type we want to render
๐ src/webgl-hello-world.js
gl.useProgram(program);
- gl.drawArrays();
+ gl.drawArrays(gl.POINTS);
There is a way to pass input data containing info about positions of our primitives to vertex shader, so we need to pass the index of the first primitive we want to render
๐ src/webgl-hello-world.js
gl.useProgram(program);
- gl.drawArrays(gl.POINTS);
+ gl.drawArrays(gl.POINTS, 0);
and primitives count
๐ src/webgl-hello-world.js
gl.useProgram(program);
- gl.drawArrays(gl.POINTS, 0);
+ gl.drawArrays(gl.POINTS, 0, 1);
Nothing rendered ๐ข
What is wrong?
Actually to render point, we should also specify a point size inside vertex shader
๐ src/webgl-hello-world.js
const vShaderSource = `
void main() {
+ gl_PointSize = 20.0;
gl_Position = vec4(0, 0, 0, 1);
}
`;
Whoa ๐ We have a point!
It is rendered in the center of the canvas because gl_Position
is vec4(0, 0, 0, 1)
=> x == 0
and y == 0
WebGL coordinate system is different from canvas2d
canvas2d
0.0
-----------------------โ width (px)
|
|
|
โ
height (px)
webgl
(0, 1)
โ
|
|
|
(-1, 0) ------ (0, 0)-ยท---------> (1, 0)
|
|
|
|
(0, -1)
Now let's pass point coordinate from JS instead of hardcoding it inside shader
Input data of vertex shader is called attribute
Let's define position
attribute
๐ src/webgl-hello-world.js
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
const vShaderSource = `
+ attribute vec2 position;
+
void main() {
gl_PointSize = 20.0;
- gl_Position = vec4(0, 0, 0, 1);
+ gl_Position = vec4(position.x, position.y, 0, 1);
}
`;
In order to fill attribute with data we need to get attribute location. Think of it as of unique identifier of attribute in javascript world
๐ src/webgl-hello-world.js
gl.useProgram(program);
+ const positionPointer = gl.getAttribLocation(program, 'position');
+
gl.drawArrays(gl.POINTS, 0, 1);
GPU accepts only typed arrays as input, so let's define a Float32Array
as a storage of our point position
๐ src/webgl-hello-world.js
const positionPointer = gl.getAttribLocation(program, 'position');
+ const positionData = new Float32Array([0, 0]);
+
gl.drawArrays(gl.POINTS, 0, 1);
But this array couldn't be passed to GPU as-is, GPU should have it's own buffer.
There are different kinds of "buffers" in GPU world, in this case we need ARRAY_BUFFER
๐ src/webgl-hello-world.js
const positionData = new Float32Array([0, 0]);
+ const positionBuffer = gl.createBuffer(gl.ARRAY_BUFFER);
+
gl.drawArrays(gl.POINTS, 0, 1);
To make any changes to GPU buffers, we need to "bind" it. After buffer is bound, it is treated as "current", and any buffer modification operation will be performed on "current" buffer.
๐ src/webgl-hello-world.js
const positionBuffer = gl.createBuffer(gl.ARRAY_BUFFER);
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
+
gl.drawArrays(gl.POINTS, 0, 1);
To fill buffer with some data, we need to call bufferData
method
๐ src/webgl-hello-world.js
const positionBuffer = gl.createBuffer(gl.ARRAY_BUFFER);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, positionData);
gl.drawArrays(gl.POINTS, 0, 1);
To optimize buffer operations (memory management) on GPU side, we should pass a "hint" to GPU indicating how this buffer will be used. There are several ways to use buffers
-
gl.STATIC_DRAW
: Contents of the buffer are likely to be used often and not change often. Contents are written to the buffer, but not read. -
gl.DYNAMIC_DRAW
: Contents of the buffer are likely to be used often and change often. Contents are written to the buffer, but not read. -
gl.STREAM_DRAW
: Contents of the buffer are likely to not be used often. Contents are written to the buffer, but not read.When using a WebGL 2 context, the following values are available additionally:
gl.STATIC_READ
: Contents of the buffer are likely to be used often and not change often. Contents are read from the buffer, but not written.gl.DYNAMIC_READ
: Contents of the buffer are likely to be used often and change often. Contents are read from the buffer, but not written.gl.STREAM_READ
: Contents of the buffer are likely to not be used often. Contents are read from the buffer, but not written.gl.STATIC_COPY
: Contents of the buffer are likely to be used often and not change often. Contents are neither written or read by the user.gl.DYNAMIC_COPY
: Contents of the buffer are likely to be used often and change often. Contents are neither written or read by the user.gl.STREAM_COPY
: Contents of the buffer are likely to be used often and not change often. Contents are neither written or read by the user.
๐ src/webgl-hello-world.js
const positionBuffer = gl.createBuffer(gl.ARRAY_BUFFER);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
- gl.bufferData(gl.ARRAY_BUFFER, positionData);
+ gl.bufferData(gl.ARRAY_BUFFER, positionData, gl.STATIC_DRAW);
gl.drawArrays(gl.POINTS, 0, 1);
Now we need to tell GPU how it should read the data from our buffer
Required info:
Attribute size (2 in case of vec2
, 3 in case of vec3
etc)
๐ src/webgl-hello-world.js
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positionData, gl.STATIC_DRAW);
+ const attributeSize = 2;
+
gl.drawArrays(gl.POINTS, 0, 1);
type of data in buffer
๐ src/webgl-hello-world.js
gl.bufferData(gl.ARRAY_BUFFER, positionData, gl.STATIC_DRAW);
const attributeSize = 2;
+ const type = gl.FLOAT;
gl.drawArrays(gl.POINTS, 0, 1);
normalized โ indicates if data values should be clamped to a certain range
for gl.BYTE
and gl.SHORT
, clamps the values to [-1, 1]
if true
for gl.UNSIGNED_BYTE
and gl.UNSIGNED_SHORT
, clamps the values to [0, 1]
if true
for types gl.FLOAT
and gl.HALF_FLOAT
, this parameter has no effect.
๐ src/webgl-hello-world.js
const attributeSize = 2;
const type = gl.FLOAT;
+ const nomralized = false;
gl.drawArrays(gl.POINTS, 0, 1);
We'll talk about these two later ๐
๐ src/webgl-hello-world.js
const attributeSize = 2;
const type = gl.FLOAT;
const nomralized = false;
+ const stride = 0;
+ const offset = 0;
gl.drawArrays(gl.POINTS, 0, 1);
Now we need to call vertexAttribPointer
to setup our position
attribute
๐ src/webgl-hello-world.js
const stride = 0;
const offset = 0;
+ gl.vertexAttribPointer(positionPointer, attributeSize, type, nomralized, stride, offset);
+
gl.drawArrays(gl.POINTS, 0, 1);
Let's try to change a position of the point
๐ src/webgl-hello-world.js
const positionPointer = gl.getAttribLocation(program, 'position');
- const positionData = new Float32Array([0, 0]);
+ const positionData = new Float32Array([1.0, 0.0]);
const positionBuffer = gl.createBuffer(gl.ARRAY_BUFFER);
Nothing changed ๐ข But why?
Turns out โ all attributes are disabled by default (filled with 0), so we need to enable
our position attrbiute
๐ src/webgl-hello-world.js
const stride = 0;
const offset = 0;
+ gl.enableVertexAttribArray(positionPointer);
gl.vertexAttribPointer(positionPointer, attributeSize, type, nomralized, stride, offset);
gl.drawArrays(gl.POINTS, 0, 1);
Now we can render more points!
Let's mark every corner of a canvas with a point
๐ src/webgl-hello-world.js
const positionPointer = gl.getAttribLocation(program, 'position');
- const positionData = new Float32Array([1.0, 0.0]);
+ const positionData = new Float32Array([
+ -1.0, // point 1 x
+ -1.0, // point 1 y
+
+ 1.0, // point 2 x
+ 1.0, // point 2 y
+
+ -1.0, // point 3 x
+ 1.0, // point 3 y
+
+ 1.0, // point 4 x
+ -1.0, // point 4 y
+ ]);
const positionBuffer = gl.createBuffer(gl.ARRAY_BUFFER);
gl.enableVertexAttribArray(positionPointer);
gl.vertexAttribPointer(positionPointer, attributeSize, type, nomralized, stride, offset);
- gl.drawArrays(gl.POINTS, 0, 1);
+ gl.drawArrays(gl.POINTS, 0, positionData.length / 2);
Let's get back to our shader
We don't necessarily need to explicitly pass position.x
and position.y
to a vec4
constructor, there is a vec4(vec2, float, float)
override
๐ src/webgl-hello-world.js
void main() {
gl_PointSize = 20.0;
- gl_Position = vec4(position.x, position.y, 0, 1);
+ gl_Position = vec4(position, 0, 1);
}
`;
const positionPointer = gl.getAttribLocation(program, 'position');
const positionData = new Float32Array([
- -1.0, // point 1 x
- -1.0, // point 1 y
+ -1.0, // top left x
+ -1.0, // top left y
1.0, // point 2 x
1.0, // point 2 y
Now let's move all points closer to the center by dividing each position by 2.0
๐ src/webgl-hello-world.js
void main() {
gl_PointSize = 20.0;
- gl_Position = vec4(position, 0, 1);
+ gl_Position = vec4(position / 2.0, 0, 1);
}
`;
Result:
Conclusion
We now have a better understanding of how does GPU and WebGL work and can render something very basic
We'll explore more primitive types tomorrow!
Homework
Render a Math.cos
graph with dots
Hint: all you need is fill positionData
with valid values
Join mailing list to get new posts right to your inbox
Built with
Top comments (0)