hello shaders

Starthing with webgl2 in the web

Shaders have quietly become part of the modern web. They're used everywhere: animated backgrounds, fancy buttons, particle systems, image effects, and entire interactive experiences.

I've played with shaders a few times before, but always stopped before really understanding them. This time I'm starting from scratch and documenting everything I learn.

To keep things simple, I'm using p5.js. It hides a lot of the WebGL boilerplate so we can spend more time experimenting with the interesting part: the math.

GLSL stands for OpenGL Shading Language. glsl for open gl shader language

Setting up p5

You can follow along using the p5 online editor or simply include the library:

html · setup
<link rel="stylesheet" href="/demo/reset.css">
<script src="https://cdn.jsdelivr.net/npm/p5@2.3.0/lib/p5.min.js"></script>

Let's make sure everything works before touching shaders.

js · run
function setup() {
  // Create a drawing canvas 400 pixels wide and 400 pixels high
  createCanvas(400, 400); 
}

function draw() {
  // Set the background color to a light gray
  background(123); 
  
  // Customize the text
  textSize(32);
  textAlign(CENTER, CENTER);
  fill(0); // Black text
  
  // Display the text at the center of the canvas
  text("Hello World 👋", width / 2, height / 2);  
}

If you see Hello World, we're ready.


The two shaders

Every shader program is made of two small programs running directly on the GPU.

  • Vertex Shader — decides where geometry exists.
  • Fragment Shader — decides the color of every pixel.

Think of the pipeline like this:

flowchart TD
    CPU[CPU]
    VS[Vertex Shader]
    FS[Fragment Shader]
    Screen[Screen]

    CPU --> VS
    VS --> FS
    FS --> Screen

For simple 2D rendering, the vertex shader is almost boring.

Its only real job is assigning a value to the built-in variable gl_Position.

shader.vert · glsl file
attribute vec3 aPosition;
void main(){ 
  gl_Position = vec4(aPosition, 1.0); 
}

aPosition comes from p5.js. gl_Position tells the GPU where this vertex should appear. The fragment shader is much more interesting. It runs once for every pixel that ends up on the screen. Here we're simply outputting a changing color.

shader.frag · glsl file
precision mediump float;
uniform float uTime;
void main(){
  gl_FragColor = vec4(abs(sin(uTime)), 0.4, 0.7, 1.0); 
}

vec4 represents: red, Green, Blue, Alpha. Each value normally ranges from 0.0 to 1.0.

Running the shader

Now we load both files into p5.

js · run
async function setup(){
  createCanvas(300,300, WEBGL) 
    myShader = await loadShader("shader.vert", "shader.frag")
}

function draw(){
  shader(myShader)
  myShader.setUniform('uTime', millis()/2000.0)
  rect(-width/2, -height/2, width,height)
}

setUniform() sends data from JavaScript to the GPU.

In this example we're sending elapsed time every frame, allowing the fragment shader to animate.


WebGL2 and the new GLSL syntax

Most tutorials you'll find online use the older GLSL syntax.

Modern browsers support WebGL2, which uses OpenGL ES 3.0.

That means we get a cleaner shader syntax.

The first line of both shader files must be:

glsl
#version 300 es

The vertex shader becomes:

shader.vert · glsl file
#version 300 es 
precision mediump float;

in vec3 aPosition;
out vec2 vPos;
  
void main(){ 
  vPos = aPosition.xy;
  gl_Position = vec4(aPosition, 1.0);
}

Notice the new keywords.

OldNew
attributein
varyingout / in
gl_FragColorcustom output variable

The fragment shader also changes.

shader.frag · glsl file
#version 300 es 
precision mediump float;

in vec2 vPos;
out vec4 fragColor;

void main(){
 fragColor = vec4(0.9, .0, 0.3, 1.0);
}

Instead of writing to gl_FragColor, we create our own output variable.


Passing data between shaders

One of the most important concepts is that the vertex shader can send data to the fragment shader

flowchart TD
    CPU["CPU / JavaScript"]

    VS["Vertex Shader"]
    FS["Fragment Shader"]

    CPU -.->|uniforms| VS
    CPU -.->|uniforms| FS

    VS -->|out → in| FS

uniform values come directly from JavaScript.

out values are generated by the vertex shader and automatically interpolated for every pixel.

runner · js file
async function setup(){
  createCanvas(300,300, WEBGL) 
    myShader = await loadShader("shader.vert", "shader.frag")
}

function draw(){
  shader(myShader)
  rect(-width/2, -height/2, width,height)
}

Coordinates are different

One thing that need to warp head arround for a secound is:

WebGL doesn't use pixel coordinates.

Instead, the visible world goes from -1 to 1.

text
(-1,1)      (1,1)

     (0,0)

(-1,-1)    (1,-1)

p5 gives us values between 0 and 1, so we convert them ourselves.

shader.vert · glsl file
#version 300 es 
precision mediump float;

in vec3 aPosition;
out vec2 vPos;
  
void main(){ 
  vPos = aPosition.xy;
  vec4 position = vec4(aPosition, 1.0);
  position.xy = position.xy * 2.0 - 1.0;
  gl_Position = position;
}

Using position as color

Now that every fragment receives its position, we can visualize it.

Red increases as we move across the X axis.

shader.frag · glsl file · variant
- fragColor = vec4(0.9, .0, 0.3, 1.0);
+ fragColor = vec4(vPos.x, .0, 0.3, 1.0);

Or use both coordinates:

shader.frag · glsl file · variant
- fragColor = vec4(0.9, .0, 0.3, 1.0);
+ fragColor = vec4(vPos.y, vPos.x, 0.3, 1.0);

Without writing any complicated math we've already created a smooth gradient.

That's because the GPU automatically interpolates values between vertices.

Making it move

Static gradients are nice. Shaders become much more fun once time enters the picture. We already send a uniform every frame:

runner · js file
async function setup(){
  createCanvas(300,300, WEBGL) 
    myShader = await loadShader("shader.vert", "shader.frag")
}

function draw(){
  shader(myShader)
  myShader.setUniform('uTime', millis() / 2000 )
  rect(-width/2, -height/2, width,height)
}

now in frag we grab that data by declere uniform and fragment shader can animate using sin().

shader.frag · glsl file
#version 300 es 
precision mediump float;

in vec2 vPos;
out vec4 fragColor;
uniform float uTime;

void main(){
 float x = sin(vPos.x + uTime); // cupped it between -1 to 1
 float y = sin(vPos.y + uTime); // cupped it between -1 to 1
 fragColor = vec4(x, y, 0.3, 1.0);
}

sin() oscillates smoothly between -1 and 1, making it one of the most common functions you'll use in shader programming.

we can change the shape from rect to different shapes p5 support, let's render the shader onto a circle instead of a rectangle.

runner · js file
- rect(-width/2, -height/2, width,height)
+ ellipse (0,0, width, height, 10)

the 10 in the end of ellipse function tell how much points will use the render that ellipse.

Based on the excellent tutorial Intro to Shaders