Learn with me: Demystifying Shaders and Three.js Shading Language

Welcome to the fascinating intersection of art and code! Today, I'm launching a new series on my blog called "Learn with me", where I'll document my learning journey on various technical subjects. And to inaugurate this series, I've chosen a topic that both fascinates and terrifies me: shaders and particularly the new TSL (Three.js Shading Language) .

Why "Learn with me"? Because I firmly believe that the best way to learn is to teach, even when you're still learning yourself. It's a bit like live-coding my brain as it assimilates new concepts. So expect moments of eureka, honest confusions, and perhaps some instructive mistakes along the way.

So, buckle up, we're diving into the mysterious world of shaders!

What exactly is a shader?

If you've worked with Three.js or WebGL before, you've probably heard of shaders. But what exactly are they? Simply put, imagine your GPU as an army of tiny, super-fast robots with paintbrushes, but they only understand very specific commands. Shaders are like the detailed instruction manual that tells each robot exactly which color to use and where to place every single brushstroke to create your digital experience. And these little robots work in perfect unison at lightning speed—millions of them painting your screen several times per second!

The fundamental role of shaders in graphic rendering

Shaders are special programs written in GLSL (OpenGL Shading Language) that run directly on your GPU. They are one of the main components of WebGL and are responsible for two essential tasks: positioning each "vertex" (point) of a 3D geometry and coloring each visible pixel of that geometry.

In fact, the term "pixel" isn't quite accurate in this context. In the shader world, we talk about "fragments", which are graphical processing units before they become pixels on the screen.

To accomplish these tasks, we send a multitude of data to the shaders including vertex coordinates, mesh transformations, camera information, colors, textures, lighting parameters, and much more.

The two main types of shaders

There are mainly two types of shaders you'll regularly encounter:

  1. Vertex Shader: This is the first to come into play. Its role is to determine where each vertex (point) of your geometry should be positioned in 3D space, then projected onto your 2D screen. It's like commanding your robot army's positioning squad: "Robot #1, stand at coordinates X,Y,Z! Robot #2, three steps to the left and two up! Robot #3, form a triangle with the others!"
  2. Fragment Shader: Once all the vertices are positioned, the fragment shader comes into play. Its mission is to determine the color of each fragment (future pixel) visible in your geometry. Now you're instructing your coloring squad: "Robots between points A and B, use red paint! Robots near the edge, create a blue-to-green gradient! And you robots in the center, apply this fancy texture pattern!"

How do shaders work?

The GLSL language and its ecosystem

Shaders are written in GLSL, a language that resembles C with some specifics for graphical processing. If you've never written GLSL before, here's a little preview:

precision mediump float;

void main()
{
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // it's like #ff0000 or rgb(255 0 0 / 100%) in CSS
}

This simple code is a fragment shader that colors all fragments in red. A few points to note:

  • precision mediump float; defines the precision of floating-point numbers. Options are highp, mediump, and lowp.
  • gl_FragColor is a predefined variable that contains the final color of the fragment.
  • vec4(1.0, 0.0, 0.0, 1.0) represents an RGBA color (Red, Green, Blue, Alpha).

GLSL is a typed language, which means you must specify the type of each variable:

float a = 1.0;        // Floating-point number
int b = 2;            // Integer
bool c = true;        // Boolean
vec2 d = vec2(1.0, 2.0);  // 2-dimensional vector
vec3 e = vec3(1.0, 2.0, 3.0);  // 3-dimensional vector
vec4 f = vec4(1.0, 2.0, 3.0, 4.0);  // 4-dimensional vector

The rendering pipeline

Here's how the rendering pipeline works with shaders:

The process begins when you send a geometry (a set of vertices) to the GPU. The vertex shader processes each vertex individually, determining its final position. Then, the GPU determines which fragments (potential pixels) are covered by the geometry. Next, the fragment shader processes each fragment individually, determining its final color. Finally, the result is displayed on the screen.

It's a bit like an assembly line where each worker (shader) has a specific task to accomplish.

Essential components of shaders

Uniforms - Constant data for all vertices

"Uniforms" are variables that remain constant for all vertices or fragments during a render cycle. They're perfect for values like elapsed time (for animations), transformation matrices, global colors, and configuration parameters.

Here's how you might define and use a uniform in Three.js:

const material = new THREE.RawShaderMaterial({
    vertexShader: vertexShader,
    fragmentShader: fragmentShader,
    uniforms: {
        uTime: { value: 0 },
        uColor: { value: new THREE.Color('orange') }
    }
});

// In your animation loop
function animate() {
    material.uniforms.uTime.value = clock.getElapsedTime();
    requestAnimationFrame(animate);
}

And in your shader:

uniform float uTime;
uniform vec3 uColor;

// Usage in code...

Attributes - Unique data for each vertex

Unlike uniforms, "attributes" are data that vary for each vertex. Typical examples include vertex position, vertex normal, UV coordinates for textures, and custom values for special effects.

Here's how you might define a custom attribute in Three.js:

const geometry = new THREE.PlaneGeometry(1, 1, 32, 32);
const count = geometry.attributes.position.count;
const randoms = new Float32Array(count);

for(let i = 0; i < count; i++) {
    randoms[i] = Math.random();
}

geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));

And in your vertex shader:

attribute float aRandom;

void main() {
    // Using aRandom to modify position, for example
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    modelPosition.z += aRandom * 0.1;

    // Rest of code...
}

Varyings - Communication between vertex and fragment shaders

"Varyings" are variables that allow data to be passed from the vertex shader to the fragment shader.

In the vertex shader:

attribute float aRandom;
varying float vRandom;

void main() {
    // ...
    vRandom = aRandom;
    // ...
}

In the fragment shader:

varying float vRandom;

void main() {
    gl_FragColor = vec4(vRandom, 0.5, 1.0, 1.0);
}

Three.js and shaders

RawShaderMaterial vs ShaderMaterial

Three.js offers two main classes for working with custom shaders:

  1. RawShaderMaterial: Gives you total control and requires you to define all variables yourself, including projection, view, and model matrices. It's like cooking from scratch.
// With RawShaderMaterial, you need to define everything yourself
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;

attribute vec3 position;

void main() {
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
  1. ShaderMaterial: Predefines many commonly used variables, allowing you to focus on the specific logic of your shader. It's like using a cooking kit with pre-measured ingredients.
// With ShaderMaterial, many things are predefined
void main() {
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}

The choice between the two depends on your comfort level with GLSL and how much control you need.

Introduction to TSL (Three.js Shading Language)

And this is where TSL, or Three.js Shading Language, comes in - the new approach to creating shaders in Three.js. TSL aims to simplify the shader creation process while maintaining the power and flexibility of GLSL.

Why TSL is a game-changer

TSL is designed to be more accessible to JavaScript developers, with a more familiar syntax and abstractions that hide some of the complexities of GLSL. It also allows for better integration with the Three.js ecosystem, making it easier to share and reuse shaders.

Conclusion

Shaders are like the black magic of graphics development - powerful, mysterious, and sometimes intimidating. But like any magic, once you understand the underlying principles, they become incredibly powerful tools in your creative arsenal.

In this "Learn with me" series, we'll demystify shaders and explore the new TSL together. I don't claim to be an expert - I'm a learner, just like you. And that's precisely what makes this series special: we'll discover, experiment, and sometimes fail together, but we'll all come out with a better understanding.

So, whether you're a seasoned Three.js developer curious about TSL, or a complete beginner in the world of shaders, I hope you'll join me on this adventure!