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.
MeshStandardMaterialcarries 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
RawShaderMaterialgives you nothing: you declare every uniform, attribute, and the precision yourself. Good for learning what actually existsShaderMaterialprepends the common boilerplate for you:projectionMatrix,viewMatrix,modelMatrix,modelViewMatrix,normalMatrix,cameraPosition, theposition,uv, andnormalattributes, andprecision mediump float;. Do not redeclare them or the shader fails to compile- Standard material properties like
wireframe,side,transparent,flatShadingstill work on both. Properties likemap,color,opacitydo nothing: those features live in shader code you now own - An alpha below 1.0 in
gl_FragColorneedstransparent: trueon 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. Onlyvalueis 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
.glslout of the box. Installvite-plugin-glsland 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-glslandvite-plugin-glslifyalso 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, not1. Mixingfloatandintin one operation is a compile error; convert explicitly withfloat(b)orint(a) - Vectors:
vec2(x,y),vec3(addsz, aliasesr,g,b),vec4(addsw, aliasa). 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 (
voidif 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 transformprojectionMatrix: maps view space to clip spacemodelViewMatrixisviewMatrix * modelMatrixpre-combined: shorter, less control over the intermediate stepsgl_Positionis avec4in clip space: x, y, z each range -1 to +1, anything outside is clipped,whandles perspective. Adding togl_Position.xafter 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 (highpcan be slow or unsupported,lowpcauses precision bugs).ShaderMaterialsets it for yougl_FragColoris avec4of 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 withaby convention. The secondBufferAttributeargument 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 withv
// 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 takeTHREE.Color(arrives asvec3), textures take the loaded texture (arrives assampler2D)
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; useClock.getElapsedTime() + uTimevs- uTimeflips 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 avec4. The UV comes from theuvattribute relayed as a varying - Cheap fake shading: compute the displacement in the vertex shader, pass it as
vElevation, multiplytextureColor.rgb *= vElevation * 2.0 + 0.5in 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 everythingShaderMaterialprepended - 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.xorvUv.yis a linear gradient.1.0 - vUv.yinverts it.vUv.y * 10.0squeezes 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 gradientstep(edge, value)returns 0.0 below the edge, 1.0 above: the branchless cutoff that turns gradients into hard bands. Prefer it overiffor performance- Combine axes: add (
+) for a full grid of crossing lines, multiply (*) to keep only intersections (dots). Different edges per axis give dashes; offsets beforemodshift 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 outlinedistance(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 onwavedUv. Raising the sin frequency turns a wobbly circle psychedelic
Steps as posterize: floor
floor(vUv.x * 10.0) / 10.0quantizes 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 avec2into 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); addvUv.xinto 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.xnow 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
cnoisedepends on apermutefunction 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 floatt: 0.0 returnsa, 1.0 returnsb, outside 0 to 1 extrapolates. Usestrengthastto 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 pushstrengthabove 1.0, andmixthen 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, orprecisionwithShaderMaterial, which already prepends them - Writing
1where GLSL demands1.0, or multiplying afloatby anintwithout 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: trueon the material - Reading an attribute in the fragment shader instead of relaying it through a varying
- Expecting
material.colorormapto 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_Positionafter projection instead ofmodelPositionbefore the view transform - Using
ifwherestep()does the same cutoff branchlessly - Pasting Perlin noise without the
permutehelper 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/rotationstill work fine