Creating PIXI.js filters using WebGL

Introduction

PIXI.js 3.0 is a great library for creating 2d canvas games and animations. It's one of the most used canvas renderers over the Web. It also supports WebGL rendering, thanks to which most of the operations are executed by GPU instead of CPU. It comes with a nice filters feature that has great possibilites and is quite easy to use and extend. In this article we will focus on describing how to create a custom filter using Fragment shaders.

Fragment and Vertex shaders

What exactly the Fragment and Vertex shaders are and what is the difference between those two? Shader is a set of instructions that get executed by GPU. These instructions are designed to work with graphic data and are very efficient. The Fragment shader operations are executed on pixels (textures) and Vertex shader operations are executed on vertices.

Fragment shader

We write shaders using the GLSL language. It's based on the syntax of the C programming language. Every Fragment shader takes a form similar to the code in the listing below:

precision mediump float;
 
void main(void) {
  gl_FragColor = doMathToMakeAColor;
}

As you can see, there is the main function, and an assignment to the gl_FragColor variable. This shader is executed over and over again for each pixel of a texture. The gl_FragColog is computed color of the pixel in the form of 4 dimensional vector. The first three values are RGB and the last one is alpha channel. You have to remember that white color is not [255,255,255,255] but [1.0, 1.0, 1.0, 1.0] and of course the black color is [0.0, 0.0, 0.0, 1.0]. Values in shaders are always floats and you always have to provide the .0 sufix even if it's an integer value.

Data types

Fragment shaders operate on three data types:

  • Uniforms - values that stay the same for every pixel of a single draw call
  • Textures - data from pixels
  • Varyings - data passed from the vertex shader and interpolated

There are a few variables that will appear over and over again in PIXI.js Fragment shaders. They are:

  • varying vec2 vTextureCoord - it's a position of the currently processed pixel (2 dimensional vector of float values)
  • uniform vec4 dimensions - it's a 4 dimentional vector whose first value is canvas width and the second is canvas height (in pixels)
  • uniform sampler2D uSampler - it'a a pixels data of the texture being processed

It's worth noting that a value of the vTextureCoord variable is a 2 dimensional vector of float values ranging from 0.0 to 1.0. It means that the [0.0, 0.0] value translates to the [0, 0] pixel of canvas and the [1.0, 1.0] value translates to the [canvasWidth, canvasHeight] pixel. So let's repeat it one more time:

  • Values are floats, not integers
  • They are from the range 0.0 to 1.0
  • Coordinates refer to a point in the canvas not in the sprite

Simple fragment shader

The most simple fragment shader that does nothing could look like in the listing below:

precision mediump float;

varying vec2 vTextureCoord;
uniform sampler2D uSampler;
 
void main(void) {
  gl_FragColor = texture2D(uSampler, vTextureCoord);
}

What it does is actually taking current pixel data and just returning it without any operation. The texture2D function takes the pixels data as the first argument and the pixel coordinates as the second.

Let's try doing something with pixel's data, like multiplying each value by 0.5.

precision mediump float;

varying vec2 vTextureCoord;
uniform sampler2D uSampler;
 
void main(void) {
  vec4 pixel = texture2D(uSampler, vTextureCoord);
  pixel *= 0.5;
  gl_FragColor = pixel;
}

As you can see, we take the data of the pixel being currently processed an store it in the vec4 pixel variable. The next thing we do is multiplying each vector's value by 0.5. At the end, we return processed pixel's data.

Accessing vector's properties

There are up to 4 dimensions of a vector and we can access them in few different ways:

  • v.x === v.s === v.r === v[0]
  • v.y === v.t === v.g === v[1]
  • v.z === v.p === v.b === v[2]
  • v.w === v.q === v.a === v[3]

In the list above each assignment in the given line is equal. We can access the first vector's property using v.xv.sv.r or v[0] notation. Knowing that, let's try to remove the red color from a texture.

precision mediump float;

varying vec2 vTextureCoord;
uniform sampler2D uSampler;
 
void main(void) {
  vec4 pixel = texture2D(uSampler, vTextureCoord);
  pixel.r = 0.0; // Remove red color by assigning 0.0
  gl_FragColor = pixel;
}

Creating a custom filter

Right now, we can move forward and create a custom filter that removes the red color. Let's start from the frame code.

function CustomFilter() {
  var vertexShader = null;
  var fragmentShader = null; // Fragment shader code
  var uniforms = {};

  PIXI.AbstractFilter.call(this,
    vertexShader,
    fragmentShader,
    uniforms
  );
}

CustomFilter.prototype = Object.create(PIXI.AbstractFilter.prototype);
CustomFilter.prototype.constructor = CustomFilter;

There are X important things that you have to do when creating a custom filter.

  1. Define custom class / constructor for a filter
  2. Provide vertex and fragment shader code as a string or pass null
  3. Provide uniforms
  4. Call the PIXI.AbstractFilter constructor in the context of an instance of our custom filter and pass data from points 1 to 3
  5. Use prototypal inheritance to make our custom filter a subclass of the PIXI.AbstractFilter

Now let's see how the NoRedFilter could look like.

function NoRedFilter() {
  var vertexShader = null;
  var fragmentShader = [
    'precision mediump float;',
    '',
    'varying vec2 vTextureCoord;',
    'uniform sampler2D uSampler;',
    '',
    'void main(void)',
    '{',
    '    vec4 pixel = texture2D(uSampler, vTextureCoord);',
    '    pixel.r = 0.0;',
    '    gl_FragColor = pixel;',
    '}'
  ].join('\n');
  var uniforms = {};

  PIXI.AbstractFilter.call(this,
    vertexShader,
    fragmentShader,
    uniforms
  );
}

NoRedFilter.prototype = Object.create(PIXI.AbstractFilter.prototype);
NoRedFilter.prototype.constructor = NoRedFilter;

OffsetFilter

Now, let's move to a more complex example. We will create the OffsetFilter filter that moves texture in the sprite by given offset. At the end of this article, you can download application that shows usage of that filter.

Let's start with the code:

function OffsetFilter() {
  var vertexShader = null;

  var fragmentShader = [
    'precision mediump float;',
    '',
    'varying vec2 vTextureCoord;',
    '',
    'uniform vec4 dimensions;',
    'uniform vec2 offset;',
    'uniform sampler2D uSampler;',
    '',
    'void main(void)',
    '{',
    '    vec2 pixelSize = vec2(1.0) / dimensions.xy;',
    '    vec2 coord = vTextureCoord.xy - pixelSize.xy * offset;',
    '',
    '    if (coord.x < 0.0 || coord.y < 0.0 || coord.x > 1.0 || coord.y > 1.0) {',
    '        gl_FragColor = vec4(0.0);',
    '    } else {',
    '        gl_FragColor = texture2D(uSampler, coord);',
    '    }',
    '}'
  ].join('\n');

  var uniforms = {
    dimensions: {
      type: '4fv',
      value: new Float32Array([0, 0, 0, 0])
    },
    offset: {
      type: 'v2',
      value: {
        x: 50,
        y: 50
      }
    }
  };

  PIXI.AbstractFilter.call(this, vertexShader, fragmentShader, uniforms);
}

OffsetFilter.prototype = Object.create(PIXI.AbstractFilter.prototype);
OffsetFilter.prototype.constructor = OffsetFilter;

Object.defineProperties(OffsetFilter.prototype, {
  offset: {
    get: function() {
      return this.uniforms.offset.value;
    },
    set: function(value) {
      this.uniforms.offset.value = value;
    }
  }
});

It's very similar to the NoRedFilter filter with a few differences. First of all, we've defined uniforms that will be visible in the shader's code. The dimensions uniform had already been described and it's definition is imposed by PIXI.js. The next uniform is offset. It's a 2 dimensional vector so that's why we set it's type to 'v2'. The default value of it is the {x: 50, y:50} object. We've also defined a setter and getter for this uniform. Thanks to that we can access it directly from the level of the filter.

var filter = new OffsetFilter();

filter.offset = {
  x: 50,
  y: 100
};

sprite.filters = [filter];

The last thing is a fragment shader code.

precision mediump float;

varying vec2 vTextureCoord;

uniform vec4 dimensions;
uniform vec2 offset;
uniform sampler2D uSampler;

void main(void)
{
    vec2 pixelSize = vec2(1.0) / dimensions.xy;
    vec2 coord = vTextureCoord.xy - pixelSize.xy * offset;

    if (coord.x < 0.0 || coord.y < 0.0 || coord.x > 1.0 || coord.y > 1.0) {
        gl_FragColor = vec4(0.0);
    } else {
        gl_FragColor = texture2D(uSampler, coord);
    }
}

We declare two uniforms, that we've defined in the JS code of the filter.

The next thing we do is calculating pixel size, where canvas width and height are not pixel values but 1.0. To calculate it, we have to divide 1.0 by canvas width and do the same with the height. Let's assume that the canvas width is equal 400 px. The operation would look like this:

1.0 / 400 = 0.0025

The 0.0025 is the size of one pixel. As you can see in the shader's code, we do this operation on vectors instead of scalars. We divide the [1.0, 1.0] (vec2(1.0) vector by the [canvasWidth, canvasHeight] (dimensions.xy) vector. As a result of this operation we get another vector where the 0 indexed value is the pixel width and the 1 indexed value is the pixel height.

Then, we have to convert the value of the offset uniform into our new unit by executing the following line of the code: pixelSize.xy * offset. Now, instead of taking coordinates of the pixel being currently processed, we take a pixel that is offset: vec2 coord = vTextureCoord.xy - pixelSize.xy * offset;

The last thing we have to do is deciding what to do with the space that get revealed after moving the texture. We decided to make it transparent: vec4(0.0).

Summary

It was just an introduction to creating PIXI.js filters. In the next articles, we will focus on vertex shaders and creating more advanced filters. The best way to learn how filters work is investigating currently existing PIXI.js filters. I really encourage you to do it, if you are interesed in the subject.

List
SDK Version Since: 
2.3.0