Pi.js shaders allow developers to change the pixels of a screen with code that runs on the graphics card. A shader is a small program that runs once for every pixel, which makes effects such as color changes, distortion, and scanlines fast enough to use on every frame. Shaders are written in GLSL ES 3.00, the shading language of WebGL 2. Pi.js takes care of the setup, so you only write the fragment shader, which is the part that picks the color of each pixel.
Creating a Shader
The createShader function takes the source code of a fragment shader and returns a shader handle. Creating a shader does not draw anything. The applyShader function runs the shader over the pixels of the screen. Everything drawn before applyShader is changed by the shader, and anything drawn after it appears on top. You can call applyShader more than once to stack effects.
Every shader starts with the same few lines. The first line must be “#version 300 es”, followed by a precision line. The v_texCoord input is the position of the current pixel, from 0 to 1 across the screen. The u_texture uniform holds the pixels of the screen, and the color written to fragColor replaces the current pixel.
let invert = $.createShader( `#version 300 es
precision mediump float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
vec4 color = texture( u_texture, v_texCoord );
fragColor = vec4( color.a - color.rgb, color.a );
}` );
$.screen( "300x200" );
$.circle( 100, 100, 60, "red" );
$.rect( 150, 60, 100, 80, "blue" );
// Invert the colors of everything drawn so far
$.applyShader( invert );
// Drawn after the shader, so it is not inverted
$.setColor( "yellow" );
$.line( 0, 0, 299, 199 );Built-in Uniforms
A uniform is a value that is passed into a shader. Pi.js sets a few uniforms for you, and a shader only has to declare the ones it uses. The u_texture uniform holds the pixels of the screen. The u_sourceSize uniform is the size of the screen in pixels, and u_outputSize is the size of the area the shader draws to. The u_time uniform is the time in seconds and u_frame is a frame counter, which are both useful for animation.
A shader only runs when it is applied, so an animated effect needs to draw the screen and apply the shader on every frame.
let wave = $.createShader( `#version 300 es
precision mediump float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
uniform float u_time;
out vec4 fragColor;
void main() {
vec2 uv = v_texCoord;
// Shift each row of pixels left and right over time
uv.x += sin( uv.y * 25.0 + u_time * 3.0 ) * 0.02;
fragColor = texture( u_texture, uv );
}` );
$.screen( "300x200" );
requestAnimationFrame( draw );
function draw() {
$.cls();
$.setColor( "white" );
$.print( "Wave shader", false, true );
$.rect( 90, 50, 120, 100, "green" );
$.circle( 150, 100, 35, "yellow" );
$.applyShader( wave );
requestAnimationFrame( draw );
}Custom Uniforms
You can also declare your own uniforms and set them from JavaScript. The second parameter of createShader is an object of default values, and the second parameter of applyShader overrides those values for that one call. A value can be a number, an array of numbers for a vector or a matrix, or an image.
This example sets the size of the blocks in a pixelate shader on every frame. If the second parameter of applyShader is left out, the shader uses the default block size of 4.
let pixelate = $.createShader( `#version 300 es
precision mediump float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
uniform vec2 u_sourceSize;
uniform float u_size;
out vec4 fragColor;
void main() {
// Use the color at the center of each block of pixels
vec2 pixel = v_texCoord * u_sourceSize;
vec2 block = floor( pixel / u_size ) * u_size + u_size * 0.5;
fragColor = texture( u_texture, block / u_sourceSize );
}`, { "u_size": 4 } );
let angle = 0;
$.screen( "300x200" );
requestAnimationFrame( draw );
function draw() {
angle += 0.02;
let size = 1 + Math.abs( Math.sin( angle ) ) * 11;
$.cls();
$.setColor( "white" );
$.circle( 100, 110, 60, "red" );
$.circle( 200, 110, 60, "blue" );
$.applyShader( pixelate, { "u_size": size } );
// Printed after the shader, so the text is not pixelated
$.print( "Block size: " + Math.round( size ) );
requestAnimationFrame( draw );
}Display Shaders
The setDisplayShader function sets a shader that runs when the screen is shown on the page. A display shader does not change the pixels of the screen, so drawing and reading pixels work the same as before. It also runs at the full size of the canvas on the page rather than at the size of the screen, so a small screen that is stretched to fill the window can have effects that are finer than its own pixels. In a display shader, u_sourceSize is the size of the screen and u_outputSize is the size of the canvas. This makes display shaders a good fit for CRT effects, custom scaling, and color grading.
A display shader stays active until it is replaced, and passing null to setDisplayShader removes it. The setDisplayShaderUniforms function changes the uniforms of the active display shader and shows the screen again with the new values.
let crt = $.createShader( `#version 300 es
precision mediump float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
uniform vec2 u_sourceSize;
uniform float u_strength;
out vec4 fragColor;
void main() {
vec4 color = texture( u_texture, v_texCoord );
// Darken the lower half of each row of screen pixels
float row = fract( v_texCoord.y * u_sourceSize.y );
float scanline = 1.0 - u_strength * step( 0.5, row );
// Darken the corners
vec2 centered = v_texCoord * 2.0 - 1.0;
float vignette = 1.0 - dot( centered, centered ) * 0.25;
fragColor = vec4( color.rgb * scanline * vignette, color.a );
}`, { "u_strength": 0.4 } );
let strengths = [ 0.4, 0.8, 0 ];
let index = 0;
$.screen( "300x200" );
$.rect( 0, 0, 300, 200, "navy" );
$.circle( 150, 110, 60, "red" );
$.setColor( "white" );
$.print( "Press any key to change the scanlines." );
$.setDisplayShader( crt );
$.onKey( "any", "down", keyPress );
function keyPress() {
index = ( index + 1 ) % strengths.length;
$.setDisplayShaderUniforms( { "u_strength": strengths[ index ] } );
}Images in Shaders
A shader can read from more than one image. Declare another sampler2D uniform in the shader and set it to the name of an image loaded with loadImage, an image or canvas element, or another Pi.js screen. The image is stretched over the whole screen, so v_texCoord can be used to read from it. A shader cannot read from the same screen that it is applied to, other than through u_texture.
This example draws a striped pattern on an offscreen screen and uses it to color the shapes drawn on the main screen.
let textured = $.createShader( `#version 300 es
precision mediump float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
uniform sampler2D u_pattern;
out vec4 fragColor;
void main() {
vec4 color = texture( u_texture, v_texCoord );
vec4 pattern = texture( u_pattern, v_texCoord );
// Show the pattern only where something has been drawn
fragColor = pattern * color.a;
}` );
let main = $.screen( "300x200" );
let pattern = $.screen( { "aspect": "300x200", "isOffscreen": true } );
// Draw stripes on the offscreen screen
for( let i = 0; i < 20; i += 1 ) {
let color = i % 15 + 1;
pattern.setColor( color );
pattern.rect( i * 15, 0, 15, 200, color );
}
// Draw shapes on the main screen and fill them with the pattern
main.circle( 90, 100, 70, "white" );
main.rect( 170, 40, 110, 120, "white" );
main.applyShader( textured, { "u_pattern": pattern } );Shader Tips
Shaders work with premultiplied colors, which means the red, green, and blue values of a color have already been multiplied by its alpha value. Keep the red, green, and blue values between zero and the alpha value. To change the opacity of a color, multiply all four values. To invert a color, subtract its red, green, and blue values from its alpha value, as in the first example on this page.
The v_texCoord position starts at the bottom left of the screen with y going up, while Pi.js drawing commands start at the top left with y going down. To get the same pixel position that drawing commands use, write vec2( v_texCoord.x, 1.0 – v_texCoord.y ) * u_sourceSize in the shader.
A shader is compiled the first time it is used on a screen, so a mistake in the GLSL code throws an error at that point and not when createShader is called. The getShaderInfo function returns the source of a shader, its default uniforms, and the uniforms that were found when it was compiled. When a shader is no longer needed, the removeShader function removes it and frees its resources.
let tint = $.createShader( `#version 300 es
precision mediump float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
uniform vec3 u_tint;
out vec4 fragColor;
void main() {
vec4 color = texture( u_texture, v_texCoord );
fragColor = vec4( color.rgb * u_tint, color.a );
}`, { "u_tint": [ 1, 0.6, 0.2 ] } );
$.screen( "300x200" );
$.circle( 230, 130, 50, "white" );
$.applyShader( tint );
// List the uniforms of the shader
let info = $.getShaderInfo( tint );
$.setColor( "white" );
for( let uniform of info.screen.uniforms ) {
$.print( uniform.name + ": " + uniform.type );
}
// Remove the shader when it is no longer needed
$.removeShader( tint );Overall, Pi.js shaders give developers direct control over every pixel of a screen. The applyShader function changes what has been drawn, the setDisplayShader function changes how the screen is shown, and uniforms connect both to your JavaScript code. Whether you want a simple color effect or a full CRT display, shaders run on the graphics card, so the effect can be applied to every frame of a game.
Please checkout the Pi.js Reference page to see a full listing of shader functions.