Skip to content
Raika edited this page Jan 22, 2026 · 2 revisions

Table of Contents

1. Shader Basics

All technical research is recorded on our Miro!

Read The Book of Shaders.

A shader is a small program that runs on the GPU and controls how graphics are rendered. In GLSL, the two main types are vertex shaders and fragment shaders. The vertex shader processes the position and attributes of each vertex in 3D space, while the fragment shader calculates the final color of each pixel on the screen. It's important to note: the vertex shader can modify the positions of existing vertices but cannot create new vertices.

Shaders are used because they are highly efficient for parallel calculations. They run directly on the GPU, which is optimized for processing many vertices and pixels simultaneously. This makes shaders much faster than performing the same operations sequentially on the CPU.

1.1. Parallel Instancing

In GLSL, shader code is written as if it processes one vertex or one fragment at a time. When a draw call is executed, the GPU runs many instances of the same shader in parallel, one for each vertex or fragment. These instances are independent and cannot communicate or share memory with each other.

How the GPU runs a call in parallel
© https://thebookofshaders.com

Because of this strict parallelism, data flow is tightly controlled. The vertex shader can output values that are passed to the fragment shader through varying variables (called as out in the vertex shader and in in the fragment shader). These values are different for each vertex and are automatically interpolated across fragments. They are called varyings because their values vary per vertex and per fragment, unlike uniforms, which remain constant for all shader executions.

1.2. Vertex, Fragment & Uniforms — How They Work Together

Vertex and fragment shaders are connected in a rendering pipeline. The vertex shader runs first and processes each vertex individually. It can pass data (called varyings) to the fragment shader, which then runs for every pixel created from those vertices. This allows information like positions, normals, or texture coordinates to flow from vertices to fragments.

Uniforms are custom global variables shared by both vertex and fragment shaders. They are set from the CPU (for example via JavaScript) and remain constant during a draw call. Uniforms are used to inject external data such as time, resolution, colors, or transformation matrices into the shaders.

Attributes are per-vertex input data provided to the vertex shader. Common attributes include position, normal, and uv coordinates. Each vertex receives its own set of attribute values, meaning attributes can differ from vertex to vertex. Attributes only exist in the vertex shader, not in the fragment shader.

CPU / JavaScript
▼
Uniforms & Attributes
▼
Vertex Shader
▼
Fragment Shader
▼
Pixels

1.3. Worth Mentioning

1.3.1. Coordinate Spaces & Transformations

In computer graphics, a vertex passes through several coordinate spaces before it appears on the screen. It starts in model space, where the geometry is defined relative to its own origin. In world space, the object is positioned within the scene. View space represents the scene from the camera’s point of view, and finally clip space is used to determine what is visible on screen. In Three.js, the vertex shader usually multiplies the vertex position by the built-in matrices:

gl_Position = projectionMatrix * modelViewMatrix * vec4(v_position, 1.0);

1.3.2. Interpolation

  • Varyings are interpolated automatically between vertices
  • Interpolation is linear in screen space
  • This explains smooth color gradients, lighting, and textures

1.3.3. Precision Qualifiers

  • GLSL supports lowp, mediump, and highp
  • Important for performance, especially on mobile GPUs
  • Fragment shaders often need explicit precision declarations

1.3.4. Data Types & Vectors

  • Common types: float, vec2, vec3, vec4, mat4
  • Vector operations are built-in and optimized
  • Shaders are written in a math-oriented style

1.3.5. Branching & Performance

  • The vertex count is a bottleneck because it needs to be processed in series on the CPU first
  • if statements exist but can be expensive
  • GPUs prefer branchless code
  • Math-based conditions are often faster than logic

1.3.6. Time & Animation

  • Shaders themselves have no concept of time
  • Time must be passed in via a uniform
  • Used for animations, noise, and procedural effects

1.3.7. Determinism & Statelessness

  • Shaders are stateless
  • No memory between frames
  • Every frame is computed from scratch using inputs

2. Shader Preset (for THREE.ShaderMaterial)

Vertex Shader

precision mediump float;

// declare the varyings to pass data to the fragment shader
varying vec3 v_position;
varying vec3 v_normal;

void main() {
    // Save the varyings
    v_position = position;
    v_normal = normalize(normalMatrix * normal);

    // Vertex shader output
    gl_Position = projectionMatrix * modelViewMatrix * vec4(v_position, 1.0);
}

Fragment Shader

precision mediump float;

// Common uniforms
// uniform float u_time;
// uniform vec2 u_scroll;
// uniform vec2 u_resolution;

// varyings
varying vec3 v_position;
varying vec3 v_normal;

// include chunks
// #include <noisebase>
// #include <simplex3D>
// #include <perlin3D>
// #include <worley3D>
// #include <fbm>
// #include <curl>

void main() {
    gl_FragColor = vec4(v_normal * 0.5 + 0.5, 1.0);
}

Create Shader Material

new THREE.ShaderMaterial({
    name: 'Preset',
    uniforms: { 
        ...sharedUniforms, // include uniforms
        // Custom uniforms
    },
    vertexShader: vertexShader, // link Vertex Shader
    fragmentShader: fragmentShader // link Fragment Shader
});

3. Snippets

3.1. Noises

For most of the 3D Noises, the position can me added to the time with a scalar. (4D Noises are ment to take the time in the 4th dimension):

vec3 p = v_position * 2.0 + 0.3 * u_time;

Some noises need to be normalized from -1,1 to 0,1. This is also shown in: "Map from -1,1 to 0,1"

0.5 + 0.5 * n

// example
vec3 n = noise(v_position); // n: -1,1
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0); // 0,1

3.1.1. Worley 3D

Most common: use F1 as grayscale

float n = cellular(v_position * 3.0).x;
gl_FragColor = vec4(vec3(n), 1.0);

Use cell borders (F2 − F1)

vec2 c = cellular(v_position * 3.0);
float edge = c.y - c.x;
gl_FragColor = vec4(vec3(edge), 1.0);

3.1.2. Perlin 3D

float n = cnoise(v_position * 2.0);
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);

3.1.3. Perlin 4D

float n = cnoise(vec4(v_position * 2.0, 0.3 * u_time));
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);

3.1.4. Simplex 3D

float n = snoise(v_position * 2.0);
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);

3.1.5. Simplex 3D Gradient

Same as "Simplex 3D", but it returns additional information in "grad". Grad is a vector 3 that contains spatial derivative. That means the rate of change of a function with respect to spatial coordinates, so direction & strength.

vec3 grad;
float n = snoise(v_position * 3.0, grad);
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);

3.1.6. Simplex 4D

float n = snoise(vec4(v_position * 2.0, 0.3 * u_time));
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);

3.1.7. Fractal Brownian motion

Fractal Brownian motion (fBm) takes a noise and adds octaves to it. That means multiple layers of the noise multiplied. With every octave, the base noise function is called again. With the "step", an offset can be added to each layer (diffrent in each layer).

vec3 pos = vec3(v_position.xy, 0.3 * u_time);
float n = fbm(pos, 5, 2.0, 0.4, vec2(0.0, 0.0));
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);

This works on flat surfaces but not on spheres. For spheres, its best to map to spherical coordinates:

float scale = 1.0;

vec3 p = normalize(v_position);
float theta = atan(p.y, p.x);
float phi = acos(p.z);

vec3 sp = vec3(cos(theta) * sin(phi),
                sin(theta) * sin(phi),
                cos(phi)) * scale;

float n = fbm(sp, 5, 2.0, 0.4, vec2(0.0, 0.0));

gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);

(After the scale, motion can be added. For example: scale + 0.3 * u_time)

4.2. Map UV to -1,1

vec2 p = v_uv * 2.0;
vec2 q = p - vec2(0.5, 0.5);

// modify vec2 to move element
vec2 q = p - vec2(0.3, 0.6);

4.3. Map from -1,1 to 0,1

0.5 + 0.5 * n

// example
vec3 n = noise(v_position); // n: -1,1
gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0); // 0,1

4.4. Custom smoothstep function of f

float u = f * f * ( 3.0 - 2.0 * f );
float u = smoothstep( 0.0, 1.0, f );

4.5. Raymarch - basic SDF setup

// raymarch definitions
#define STEPS 64 // loop steps
#define MAX_DIST 5.0 // far plane from camera -> cam.pos: 4.0 + radius: 0.5
#define SURF_DIST 0.001 // acurracy threshold 

// signed distance to sphere -> SDF
float sdfSphere(vec3 p, float r) {
    return length(p) - r; // returns where the current ray point is -> negative: in the sphere / positive: outside / 0: surface
}

// scene SDF
float map(vec3 p) {
    return sdfSphere(p, 0.5);
}

// raymarch
float raymarch(vec3 ro, vec3 rd) {
    float d = 0.0; // current traveled distance along ray
    for (int i = 0; i < STEPS; i++) {
        vec3 p = ro + rd * d; // get point at travel distance
        float h = map(p); // check distance SDF
        if (h < SURF_DIST || d > MAX_DIST) break; // break on hit or if nothing is hit after MAX_DIST
        d += h; // jump to surface
    }
    return d; // return distance to hit
}

void main() {
    // ray direction in object space
    vec3 ro = v_camPos; // ray origin
    vec3 rd = normalize(v_position - v_camPos); // ray direction

    float d = raymarch(ro, rd); // find hit of every ray (distance from camera)

    vec3 p = ro + rd * d; // hit position in space
    
    float dist = map(p); // Signed distance to surface (in ASD position and scale)

    // SURFACE
    float surface = smoothstep(0.03, 0.0, abs(dist));

    vec3 baseColor = vec3(0.29, 0.2, 0.53);
    vec3 color = baseColor * surface * 1.5;
    float alpha = clamp(surface, 0.0, 1.0);

    gl_FragColor = vec4(color, alpha);
}

5. Diffrent methods for complex shaders

Name Method Performance Look Pros / Cons Notes
Raymarched Volumetric Use a bounding mesh (sphere, cube, capsule) and compute the object procedurally per fragment using raymarching. Procedural noise, turbulence, and color are calculated in the fragment shader. Medium–Heavy ★★★★★ Pros: Fully 3D volumetric, dynamic, controllable. Cons: Shader complexity, GPU heavy. Mesh is only a container; standard for procedural volumes; good for fully dynamic effects.
Textured / Layered Mesh One or more meshes with ShaderMaterial using vertex displacement and procedural color/noise. Multiple overlapping layers can simulate depth and parallax. High ★★★★ Pros: Lightweight, easy camera rotation, fakes volumetric depth. Cons: Limited internal volume; more layers = higher GPU cost. Layering multiple meshes adds perceived volume; use additive blending and slight scale offsets for depth.
Particle System Represent the object as many small moving particles or points with color, size, and opacity. Procedural noise can drive motion or flicker. Additive blending is often used. High ★★★★ Pros: Fast, scalable, easy sparks/trails. Cons: Hard to achieve smooth volumetric look, may appear “blobby”. Works well combined with a base mesh to give depth.