webglmathshadersreact-three-fiber

The Math Behind the Magic: A Deep Dive into WebGL Shaders

Breaking down matrices, coordinate systems, and the GPU pipeline without the headache.

January 22, 20266 min readBy Himanshu Jain

I recently built a particle tunnel using React Three Fiber, but to be honest, copying shader code is very different from actually understanding it.

GLSL (the shader language) looks like C, but it behaves like nothing else. It doesn't print logs, it runs in parallel on thousands of cores, and "movement" is handled by multiplying matrices rather than changing variables.

I decided to stop copy-pasting and actually dig into the math. Here is what I learned about how a vertex actually ends up on your screen.


Part 1: The Pipeline (The Architect vs. The Artist)

The most important thing to realize is that a shader isn't one program; it is two programs playing catch.

1. The Vertex Shader (The Architect)

This program runs once for every single Point (vertex) in your geometry.

  • Input: A 3D point in space (x,y,z)(x, y, z).
  • Goal: To answer the question, "Where does this point sit on the user's 2D screen?"

2. The Fragment Shader (The Artist)

The Vertex shader defines a shape (like a triangle or a point). The Fragment shader then runs for every single Pixel inside that shape.

  • Input: Screen coordinates.
  • Goal: To answer the question, "What color is this specific pixel?"

Part 2: The Vertex Deep Dive

The vertex shader is mostly about converting coordinates. We start with a point in "Local Space" (relative to itself) and need to get it to "Clip Space" (the screen).

The Mystery of vec4

In 3D code, you often see points written with 4 numbers: vec4(x, y, z, 1.0). Why the 4th number?

This is called a Homogeneous Coordinate.

  • If w = 1.0: It represents a specific Point in space.
  • If w = 0.0: It represents a Direction (like an arrow).

We need this because of how matrices work. A 4×44 \times 4 matrix cannot "move" (translate) a 3D point unless that 4th dimension exists to catch the movement values.


Part 3: The Matrix Trilogy

The most confusing part of the shader is usually this line: gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

This single line actually represents three distinct coordinate transformations happening in sequence.

1. The Model Matrix (Local \to World)

Imagine an actor (your 3D object) standing in a T-pose in their dressing room. Their nose is at (0,0,0)(0,0,0). This is Local Space. The Model Matrix calculates the actor's position on the stage. It handles Moving, Turning, and Resizing.

2. The View Matrix (World \to Camera)

Now the actor is on stage. But we aren't filming from the center of the stage; we are filming from a camera 10 meters away. In 3D graphics, the camera never moves. To simulate a camera moving backward, the View Matrix moves the entire world forward.

3. The modelViewMatrix (The Combo)

Three.js combines these into one matrix. When you multiply your point by this, you get the position of the point relative to the camera. We use this z value to calculate size/perspective manually.


Under the Hood: The Matrix Anatomy

You might be wondering: "How can a grid of numbers actually move things?" It helps to look at exactly where the numbers sit in the 4×44 \times 4 grid.

[Rotation/ScaleRotation/ScaleRotation/ScaleTranslation XRotation/ScaleRotation/ScaleRotation/ScaleTranslation YRotation/ScaleRotation/ScaleRotation/ScaleTranslation Z0001]\begin{bmatrix} \color{red}{Rotation/Scale} & \color{red}{Rotation/Scale} & \color{red}{Rotation/Scale} & \color{blue}{Translation~X} \\ \color{red}{Rotation/Scale} & \color{red}{Rotation/Scale} & \color{red}{Rotation/Scale} & \color{blue}{Translation~Y} \\ \color{red}{Rotation/Scale} & \color{red}{Rotation/Scale} & \color{red}{Rotation/Scale} & \color{blue}{Translation~Z} \\ 0 & 0 & 0 & 1 \end{bmatrix}

The Translation Matrix (Moving)

To move a point by (x,y,z)(x, y, z), we put the numbers in the last column. This is why we need vec4! When multiplying, the matrix takes that last column and multiplies it by the vector's w component (1.01.0), effectively adding the movement to the position.

[100Tx010Ty001Tz0001]\begin{bmatrix} 1 & 0 & 0 & \mathbf{T_x} \\ 0 & 1 & 0 & \mathbf{T_y} \\ 0 & 0 & 1 & \mathbf{T_z} \\ 0 & 0 & 0 & 1 \end{bmatrix}

The Scaling Matrix (Resizing)

To make an object bigger or smaller, we multiply the numbers on the Diagonal. If you set SxS_x to 2, the object becomes twice as wide.

[Sx0000Sy0000Sz00001]\begin{bmatrix} \mathbf{S_x} & 0 & 0 & 0 \\ 0 & \mathbf{S_y} & 0 & 0 \\ 0 & 0 & \mathbf{S_z} & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

The Rotation Matrix (Turning)

Rotation is more complex and uses Trigonometry (Sine and Cosine). Here is what a rotation around the Z-Axis looks like. Notice how it mixes the X and Y values to "spin" the point.

[cos(θ)sin(θ)00sin(θ)cos(θ)0000100001]\begin{bmatrix} \cos(\theta) & -\sin(\theta) & 0 & 0 \\ \sin(\theta) & \cos(\theta) & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

Part 4: The Fragment Deep Dive

Once the vertex shader places a dot on the screen, the GPU "Rasterizes" it.

If we tell the GPU our point size is 100 pixels, it finds the 100×100100 \times 100 square of pixels on your monitor that this point covers. Then, it runs the Fragment Shader 10,000 times—once for each pixel.

From Square to Circle

By default, points are squares. To make them round, we have to sculpt them using gl_PointCoord. This acts like a tiny GPS for the particle itself.

We calculate the distance from the pixel to the center. If it's greater than 0.50.5 (meaning it's in the corner), we use discard to delete the pixel.

Coloring and Interpolation

To make the tunnel glow, we passed a "Depth Ratio" (0% to 100%) from the Vertex Shader to the Fragment Shader using a varying.

But wait—if the Fragment Shader runs 10,000 times for one point, does it recalculate the color 10,000 times? Yes.

For a single point, every pixel gets the exact same depth ratio, so every pixel calculates the exact same color. The gradient effect we see happens across the entire tunnel, because each point has a slightly different depth than its neighbor.


The Code

Here is the complete shader setup we discussed, combining all these concepts.

const vertexShader = `
  uniform float uLength;
  varying float vDepthRatio;

  void main() {
    // 1. Convert Local Space -> Camera Space
    // (This combines Model Matrix and View Matrix)
    vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);

    // 2. Convert Camera Space -> Screen (Clip) Space
    // (This applies the "Lens" and perspective math)
    gl_Position = projectionMatrix * mvPosition;

    // 3. Manual Perspective for Points
    gl_PointSize = 100.0 / -mvPosition.z;

    // 4. Calculate Depth for Color (0.0 to 1.0)
    vDepthRatio = position.z / -uLength;
  }
`;

const fragmentShader = `
  varying float vDepthRatio;

  void main() {
    // 1. Carve the Circle (Sculpting)
    // gl_PointCoord is the UV inside the specific point (0-1)
    float distanceToCenter = distance(gl_PointCoord, vec2(0.5));
    
    // If pixel is in the corner of the square, delete it
    if (distanceToCenter > 0.5) discard;

    // 2. Interpolate Color based on depth
    vec3 colorNear = vec3(1.0, 0.0, 0.5); // Pink
    vec3 colorFar = vec3(0.0, 1.0, 1.0);  // Cyan
    
    // Mix them!
    vec3 finalColor = mix(colorNear, colorFar, vDepthRatio);

    gl_FragColor = vec4(finalColor, 1.0);
  }
`;