DEV Community

Andrei Lesnitsky
Andrei Lesnitsky

Posted on • Updated on

Shaders and points

Day 2. Simple shader and triangle

This is a series of blog posts related to WebGL. New post will be available every day

GitHub stars
Twitter Follow

Join mailing list to get new posts right to your inbox

Source code available here

Built with

Git Tutor Logo


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>

Enter fullscreen mode Exit fullscreen mode

๐Ÿ“„ src/webgl-hello-world.js

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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() {
+     
+ }
+ `;

Enter fullscreen mode Exit fullscreen mode

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);
  }
  `;

Enter fullscreen mode Exit fullscreen mode

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

Quote from MDN

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);

Enter fullscreen mode Exit fullscreen mode

GLSL shader should be compiled in order to be executed

๐Ÿ“„ src/webgl-hello-world.js

  `;

  gl.shaderSource(vertexShader, vShaderSource);
+ gl.compileShader(vertexShader);

Enter fullscreen mode Exit fullscreen mode

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));

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

Ok, we're ready to draw something

๐Ÿ“„ src/webgl-hello-world.js

  gl.linkProgram(program);

  gl.useProgram(program);
+ 
+ gl.drawArrays();

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

and primitives count

๐Ÿ“„ src/webgl-hello-world.js


  gl.useProgram(program);

- gl.drawArrays(gl.POINTS, 0);
+ gl.drawArrays(gl.POINTS, 0, 1);

Enter fullscreen mode Exit fullscreen mode

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);
  }
  `;

Enter fullscreen mode Exit fullscreen mode

Whoa ๐ŸŽ‰ We have a point!

WebGL 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)
Enter fullscreen mode Exit fullscreen mode

webgl

                    (0, 1)
                      โ†‘
                      |
                      |
                      |
(-1, 0) ------ (0, 0)-ยท---------> (1, 0)
                      |
                      |
                      |
                      |
                    (0, -1)
Enter fullscreen mode Exit fullscreen mode

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);
  }
  `;


Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);


Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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);
  }
  `;


Enter fullscreen mode Exit fullscreen mode

Result:

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


GitHub stars
Twitter Follow

Join mailing list to get new posts right to your inbox

Source code available here

Built with

Git Tutor Logo

Top comments (0)