Skip to content

Shaders Fundamentals

A Three.js guide for coding agents. Also covers glsl basics, custom shader, ShaderMaterial, RawShaderMaterial, vertex shader, fragment shader, and 22 more.

Show all 28 aliases

glsl basics, custom shader, ShaderMaterial, RawShaderMaterial, vertex shader, fragment shader, gl_Position, gl_FragColor, attributes uniforms varyings, how do I pass data to a shader, how do I animate a shader, uTime uniform, vite-plugin-glsl, import glsl file, projectionMatrix modelViewMatrix, shader patterns, uv gradient, step mod stripes, grid pattern shader, perlin noise glsl, random in glsl, rotate uv, polar coordinates shader, atan angle pattern, circle in fragment shader, mix colors shader, shader not compiling, flag wave shader

Core Philosophy

  • A shader is a GLSL program run on the GPU. The vertex shader positions each vertex of a geometry in clip space, then the fragment shader colors each visible fragment. Every Three.js built-in material is just a pre-written pair of these
  • Write your own shaders to break past built-in material limits, to strip calculations down for performance, and to do post-processing. MeshStandardMaterial carries a lot of code you may not need
  • The mental model for data flow: attributes vary per vertex (vertex shader only), uniforms are constant across all vertices and fragments (both shaders), varyings carry values from vertex to fragment and get interpolated between vertices
  • Patterns are drawn with math on UV coordinates, not textures. Drawing procedurally means every parameter is animatable and there is nothing to load

ShaderMaterial vs RawShaderMaterial

  • RawShaderMaterial gives you nothing: you declare every uniform, attribute, and the precision yourself. Good for learning what actually exists
  • ShaderMaterial prepends the common boilerplate for you: projectionMatrix, viewMatrix, modelMatrix, modelViewMatrix, normalMatrix, cameraPosition, the position, uv, and normal attributes, and precision mediump float;. Do not redeclare them or the shader fails to compile
  • Standard material properties like wireframe, side, transparent, flatShading still work on both. Properties like map, color, opacity do nothing: those features live in shader code you now own
  • An alpha below 1.0 in gl_FragColor needs transparent: true on the material or it is ignored
const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0 },
    uColor: { value: new THREE.Color('orange') },
  },
})
  • The old { value: 10, type: 'float' } uniform syntax is deprecated. Only value is needed

Shader files and vite-plugin-glsl

  • Keep shaders in dedicated files (/src/shaders/<name>/vertex.glsl, fragment.glsl) for syntax highlighting and sanity. Backtick template literals work for tiny shaders only
  • Vite cannot import .glsl out of the box. Install vite-plugin-glsl and register it
// vite.config.js
import glsl from 'vite-plugin-glsl'

export default {
  plugins: [glsl()],
}
import vertexShader from './shaders/test/vertex.glsl'
import fragmentShader from './shaders/test/fragment.glsl'
  • The import resolves to a plain string. Both vite-plugin-glsl and vite-plugin-glslify also support including shader chunks inside other shaders, which matters once shaders grow or share noise functions

GLSL language basics

  • Typed, C-like, semicolons mandatory. One missing semicolon kills the whole material at compile time
  • No console, no logging: the code runs per vertex and per fragment on the GPU
  • Floats must carry a decimal point: 1.0, not 1. Mixing float and int in one operation is a compile error; convert explicitly with float(b) or int(a)
  • Vectors: vec2 (x, y), vec3 (adds z, aliases r, g, b), vec4 (adds w, alias a). One-value constructor fills every component: vec2(0.5) is (0.5, 0.5)
  • Swizzling reads components in any order and count: foo.xy, foo.yx, foo.zw. Vectors compose: vec3(someVec2, 3.0), vec4(foo.zw, vec2(5.0))
  • Multiplying a vector by a float scales every component
  • Functions declare their return type (void if none) and every parameter type
float add(float a, float b)
{
    return a + b;
}
  • Built-ins you will use constantly: sin, cos, atan, pow, mod, min, max, abs, floor, fract, clamp, step, smoothstep, mix, length, distance, dot, cross, normalize, reflect, refract, texture2D
  • No PI constant. Define it yourself: #define PI 3.1415926535897932384626433832795. Defines are cheaper than variables, immutable, conventionally UPPERCASE
  • Conditions (if) work but avoid them for performance. step() is the branchless replacement
  • Reassigning a variable through multiple lines (strength = step(0.5, strength);) costs nothing. Readability over golfed one-liners

The vertex shader and its matrices

  • The canonical position line, and what each matrix does, applied right to left:
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
  • modelMatrix: the mesh transform (position, rotation, scale)
  • viewMatrix: the inverse camera transform
  • projectionMatrix: maps view space to clip space
  • modelViewMatrix is viewMatrix * modelMatrix pre-combined: shorter, less control over the intermediate steps
  • gl_Position is a vec4 in clip space: x, y, z each range -1 to +1, anything outside is clipped, w handles perspective. Adding to gl_Position.x after projection moves the flat 2D render, not the object in 3D
  • For real control, split the chain and modify model space. This is where displacement belongs
void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    modelPosition.z += sin(modelPosition.x * 10.0) * 0.1;

    vec4 viewPosition = viewMatrix * modelPosition;
    gl_Position = projectionMatrix * viewPosition;
}

The fragment shader

  • precision mediump float; is required in a raw fragment shader (highp can be slow or unsupported, lowp causes precision bugs). ShaderMaterial sets it for you
  • gl_FragColor is a vec4 of r, g, b, a, each meant to be 0.0 to 1.0. Values outside that range do not error, they just clamp visually
void main()
{
    gl_FragColor = vec4(0.5, 0.0, 1.0, 1.0);
}

Attributes: per-vertex data from JavaScript

  • Add custom attributes on the BufferGeometry. Prefix names with a by convention. The second BufferAttribute argument is how many values make up one item (1 for a float, 3 for a vec3)
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))
attribute float aRandom;

void main()
{
    // ...
    modelPosition.z += aRandom * 0.1;
}
  • Attributes are unreadable from the fragment shader. To use one there, relay it through a varying

Varyings: vertex to fragment, interpolated

  • Declare the same varying in both shaders, assign in the vertex main, read in the fragment. Prefix with v
// vertex
varying vec2 vUv;
void main() { /* ... */ vUv = uv; }

// fragment
varying vec2 vUv;
void main() { gl_FragColor = vec4(vUv, 1.0, 1.0); }
  • Values are interpolated between vertices: a fragment halfway between varyings 0.0 and 1.0 receives 0.5. This is what makes smooth gradients from sparse vertex data possible, and it also means a varying is not the exact vertex value
  • Sending the UV this way is the prerequisite for every pattern below

Uniforms: JavaScript to shader, animatable

  • Uniforms are the control channel: same value for every vertex and fragment, changeable every frame. Prefix with u
  • Vector uniforms take THREE.Vector2/Vector3, colors take THREE.Color (arrives as vec3), textures take the loaded texture (arrives as sampler2D)
const material = new THREE.ShaderMaterial({
  vertexShader, fragmentShader,
  uniforms: {
    uFrequency: { value: new THREE.Vector2(10, 5) },
    uTime: { value: 0 },
    uTexture: { value: flagTexture },
  },
})

const tick = () => {
  material.uniforms.uTime.value = clock.getElapsedTime()
  // ...
}
uniform vec2 uFrequency;
uniform float uTime;

void main()
{
    // ...
    modelPosition.z += sin(modelPosition.x * uFrequency.x - uTime) * 0.1;
    modelPosition.z += sin(modelPosition.y * uFrequency.y - uTime) * 0.1;
}
  • Gotcha: never send huge numbers like Date.now() as a uniform. Shader float precision cannot handle them; use Clock.getElapsedTime()
  • + uTime vs - uTime flips the travel direction of a wave
  • Uniforms plug straight into a GUI: gui.add(material.uniforms.uFrequency.value, 'x')
  • Textures: sample with texture2D(uTexture, vUv), which returns a vec4. The UV comes from the uv attribute relayed as a varying
  • Cheap fake shading: compute the displacement in the vertex shader, pass it as vElevation, multiply textureColor.rgb *= vElevation * 2.0 + 0.5 in the fragment

Debugging shaders

  • Three.js logs the full compiled shader with the error line number (ERROR: 0:71: 'vec4' : syntax error). The real mistake is often the line before. The logged code also reveals everything ShaderMaterial prepended
  • No console means the debugger is gl_FragColor: pipe any suspect value into the output color and read the gradient. gl_FragColor = vec4(vUv, 1.0, 1.0); is the classic UV sanity check. Vertex-side values need a varying first

Shader patterns toolbox

All patterns run in the fragment shader on vUv (0,0 bottom-left to 1,1 top-right) and compute a float strength used as grayscale or as a mix factor:

float strength = /* pattern */;
gl_FragColor = vec4(vec3(strength), 1.0);

Gradients and inversion

  • vUv.x or vUv.y is a linear gradient. 1.0 - vUv.y inverts it. vUv.y * 10.0 squeezes it (values above 1.0 just render white)

Stripes and grids: mod + step

  • mod(value, 1.0) makes any rising value saw-tooth between 0 and 1, repeating a gradient
  • step(edge, value) returns 0.0 below the edge, 1.0 above: the branchless cutoff that turns gradients into hard bands. Prefer it over if for performance
  • Combine axes: add (+) for a full grid of crossing lines, multiply (*) to keep only intersections (dots). Different edges per axis give dashes; offsets before mod shift pieces into crosses
// stripes
float strength = step(0.8, mod(vUv.y * 10.0, 1.0));

// dotted grid: only where both axes pass
float strength = step(0.8, mod(vUv.x * 10.0, 1.0))
               * step(0.8, mod(vUv.y * 10.0, 1.0));

// crosses: two dash families added
float barX = step(0.4, mod(vUv.x * 10.0 - 0.2, 1.0)) * step(0.8, mod(vUv.y * 10.0, 1.0));
float barY = step(0.8, mod(vUv.x * 10.0, 1.0)) * step(0.4, mod(vUv.y * 10.0 - 0.2, 1.0));
float strength = barX + barY;

Center-based shapes: abs, min, max, distance

  • abs(vUv.x - 0.5) is a V-shaped gradient from the center. min(absX, absY) gives a plus-shaped dark cross, max(absX, absY) gives square rings, step(0.2, max(...)) gives a hard square frame. Multiply a frame by an inverted larger frame for a hollow square outline
  • distance(vUv, vec2(0.5)) is the radial gradient at the heart of every circular shape:
// glow / light lens: divide a small value by the distance
float strength = 0.015 / distance(vUv, vec2(0.5));

// disc
float strength = 1.0 - step(0.25, distance(vUv, vec2(0.5)));

// ring outline: abs makes a valley at radius 0.25, step carves it
float strength = 1.0 - step(0.01, abs(distance(vUv, vec2(0.5)) - 0.25));
  • Stretch the UV before measuring distance to get elongated glows; multiply two perpendicular ones for a star flare
  • Wave any shape by distorting the UV first: vec2 wavedUv = vec2(vUv.x + sin(vUv.y * 30.0) * 0.1, vUv.y + sin(vUv.x * 30.0) * 0.1); then measure distance on wavedUv. Raising the sin frequency turns a wobbly circle psychedelic

Steps as posterize: floor

  • floor(vUv.x * 10.0) / 10.0 quantizes a gradient into 10 flat bands. Multiply an x version by a y version for a 2D checker-fade

Randomness

  • GLSL has no random(). The standard hack hashes a vec2 into a pseudo-random float:
float random(vec2 st)
{
    return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
  • random(vUv) is white noise per fragment. Feed it a floored grid UV for random-valued cells (TV static blocks); add vUv.x into the y cell coordinate before flooring to skew the grid
vec2 gridUv = vec2(floor(vUv.x * 10.0) / 10.0, floor(vUv.y * 10.0) / 10.0);
float strength = random(gridUv);
  • Gotcha: bad input magnitudes make the hash show visible repeating artifacts. If the noise looks patterned, scale the input

Rotation

  • Rotating UV around a pivot is the reusable 2D rotation function. Use it whenever a pattern must be angled (rotate the coordinates, not the shape)
vec2 rotate(vec2 uv, float rotation, vec2 mid)
{
    return vec2(
        cos(rotation) * (uv.x - mid.x) + sin(rotation) * (uv.y - mid.y) + mid.x,
        cos(rotation) * (uv.y - mid.y) - sin(rotation) * (uv.x - mid.x) + mid.y
    );
}

vec2 rotatedUv = rotate(vUv, PI * 0.25, vec2(0.5));

Polar patterns: atan

  • atan(vUv.x - 0.5, vUv.y - 0.5) gives the angle around the center, range -PI to +PI. Normalize once and reuse:
float angle = atan(vUv.x - 0.5, vUv.y - 0.5) / (PI * 2.0) + 0.5; // 0.0 to 1.0 around the circle
  • Everything that worked on vUv.x now works around a circle: mod(angle * 20.0, 1.0) is a fan of wedges, sin(angle * 100.0) is radial ripples
  • Drive a radius with the angle to deform a circle into a gear or flower:
float radius = 0.25 + sin(angle * 100.0) * 0.02;
float strength = 1.0 - step(0.01, abs(distance(vUv, vec2(0.5)) - radius));

Perlin noise

  • Perlin (and simplex) noise is smooth structured randomness: clouds, water, fire, terrain, wind. There is no built-in; paste a known implementation. The go-to collection is Patricio Gonzalez Vivo's gist (github.com/patriciogonzalezvivo, gist 670c22f3966e662d2f83), with classic 2D Perlin by Stefan Gustavson the usual starting point
  • Gotcha: Gustavson's cnoise depends on a permute function some copies omit. If the shader breaks on paste, add it above the noise code:
vec4 permute(vec4 x)
{
    return mod(((x * 34.0) + 1.0) * x, 289.0);
}
  • cnoise(vec2) returns roughly -1 to 1. Scale the input to control feature size: cnoise(vUv * 10.0)
  • The noise post-processing family, one recipe each:
float strength = cnoise(vUv * 10.0);                    // raw smooth blobs
float strength = step(0.0, cnoise(vUv * 10.0));         // hard patches (cow spots, camo)
float strength = 1.0 - abs(cnoise(vUv * 10.0));         // bright ridges (lightning, water caustics)
float strength = sin(cnoise(vUv * 10.0) * 20.0);        // contour bands (marble)
float strength = step(0.9, sin(cnoise(vUv * 10.0) * 20.0)); // thin contour lines (topo map)

Mixing patterns with color

  • mix(a, b, t) blends two values of the same type by a float t: 0.0 returns a, 1.0 returns b, outside 0 to 1 extrapolates. Use strength as t to tint any black-and-white pattern
vec3 blackColor = vec3(0.0);
vec3 uvColor = vec3(vUv, 1.0);
vec3 mixedColor = mix(blackColor, uvColor, strength);
gl_FragColor = vec4(mixedColor, 1.0);
  • Gotcha: additive patterns (grid lines built with +) can push strength above 1.0, and mix then extrapolates past the target color at intersections, showing as too-bright hot spots. Clamp before mixing: strength = clamp(strength, 0.0, 1.0);

Common mistakes

  • Redeclaring position, uv, the matrices, or precision with ShaderMaterial, which already prepends them
  • Writing 1 where GLSL demands 1.0, or multiplying a float by an int without an explicit cast
  • Forgetting a semicolon, then not reading the compile error's line number that Three.js logs
  • Setting an alpha below 1.0 without transparent: true on the material
  • Reading an attribute in the fragment shader instead of relaying it through a varying
  • Expecting material.color or map to work on a shader material: those features are yours to implement
  • Sending Date.now() as a time uniform: the value is too large for shader float precision
  • Moving the mesh by editing gl_Position after projection instead of modelPosition before the view transform
  • Using if where step() does the same cutoff branchlessly
  • Pasting Perlin noise without the permute helper and staring at a broken shader
  • Mixing colors with an unclamped additive strength, which overshoots at intersections
  • Redoing mesh-level transforms in the shader when mesh.scale / position / rotation still work fine

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "shaders-fundamentals" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems