Writing your first shader
Using the framework we described in the previous section, let's build a shader together. We'll start by describing what we want to achieve. You can't get somewhere if you don't know where you're going, right?
Let's paint a rainbow arc that cycles through colors!
For this, we need to think about:
- Rainbows are arcs (partial circles), so we'll calculate distance from a center point
- We need to mask the circle to only show the top half (the arc)
- The colors cycle through the spectrum based on angle or distance
- We want it to animate, so colors should shift over time
The vertex shader
The vertex shader's job is to position our plane and pass the UV coordinates to the fragment shader. For a flat 2D effect like this, we don't need any fancy vertex displacement.
// Vertex Shader
varying vec2 vUv;
void main() {
vUv = uv; // Pass UV coordinates to fragment shader
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}That's it! The vertex shader just passes uv (the built-in texture coordinates) to our fragment shader via the vUv varying. All the interesting work happens next.
The fragment shader
This is where the magic happens. Let's build it piece by piece.
Calculate distance from center
First, we need to know how far each pixel is from the center of our plane. We'll place the center at the bottom-middle so our rainbow arcs upward.
varying vec2 vUv;
void main() {
// Center at bottom-middle of the plane
vec2 center = vec2(0.5, 0.0);
// Distance from this pixel to the center
float dist = distance(vUv, center);
// Visualize: closer = black, further = white
gl_FragColor = vec4(vec3(dist), 1.0);
}Create the rainbow bands
A rainbow has distinct color bands at different distances from the center. We'll define an inner and outer radius to create the arc thickness.
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5, 0.0);
float dist = distance(vUv, center);
// Define the rainbow's inner and outer edges
float innerRadius = 0.3;
float outerRadius = 0.6;
// Create a mask: 1.0 inside the band, 0.0 outside
float ring = smoothstep(innerRadius, innerRadius + 0.02, dist)
- smoothstep(outerRadius, outerRadius + 0.02, dist);
gl_FragColor = vec4(vec3(ring), 1.0);
}Mask to show only the top half
Rainbows are arcs, not full circles. We'll mask out the bottom half by checking if we're above the center point.
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5, 0.0);
float dist = distance(vUv, center);
float innerRadius = 0.3;
float outerRadius = 0.6;
float ring = smoothstep(innerRadius, innerRadius + 0.02, dist)
- smoothstep(outerRadius, outerRadius + 0.02, dist);
// Only show pixels ABOVE the center (y > 0.0)
float arcMask = smoothstep(0.0, 0.02, vUv.y);
float rainbow = ring * arcMask;
gl_FragColor = vec4(vec3(rainbow), 1.0);
}Add the rainbow colors
Now for the fun part! We'll map the distance to a hue value and convert HSV to RGB. The colors will vary based on where we are within the rainbow band.
varying vec2 vUv;
uniform float uTime;
// HSV to RGB conversion function
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 center = vec2(0.5, 0.0);
float dist = distance(vUv, center);
float innerRadius = 0.3;
float outerRadius = 0.6;
float ring = smoothstep(innerRadius, innerRadius + 0.02, dist)
- smoothstep(outerRadius, outerRadius + 0.02, dist);
float arcMask = smoothstep(0.0, 0.02, vUv.y);
float rainbow = ring * arcMask;
// Map distance within the band to hue (0.0 to 1.0)
float hue = (dist - innerRadius) / (outerRadius - innerRadius);
// Animate the hue over time
hue = fract(hue + uTime * 0.1);
// Convert HSV to RGB (full saturation, full value)
vec3 color = hsv2rgb(vec3(hue, 1.0, 1.0));
// Apply the rainbow mask
vec3 finalColor = color * rainbow;
gl_FragColor = vec4(finalColor, 1.0);
}Put it all together in Three.js
Now let's see the complete code to set this up in Three.js.
import * as THREE from 'three';
// Vertex Shader
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
// Fragment Shader
const fragmentShader = `
varying vec2 vUv;
uniform float uTime;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 center = vec2(0.5, 0.0);
float dist = distance(vUv, center);
float innerRadius = 0.3;
float outerRadius = 0.6;
float ring = smoothstep(innerRadius, innerRadius + 0.02, dist)
- smoothstep(outerRadius, outerRadius + 0.02, dist);
float arcMask = smoothstep(0.0, 0.02, vUv.y);
float rainbow = ring * arcMask;
float hue = (dist - innerRadius) / (outerRadius - innerRadius);
hue = fract(hue + uTime * 0.1);
vec3 color = hsv2rgb(vec3(hue, 1.0, 1.0));
vec3 finalColor = color * rainbow;
gl_FragColor = vec4(finalColor, 1.0);
}
`;
// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.1, 10);
camera.position.z = 1;
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Create the shader material
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 }
},
vertexShader,
fragmentShader
});
// A simple plane to render our shader on
const geometry = new THREE.PlaneGeometry(1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// Animation loop
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
material.uniforms.uTime.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
animate();What we learned
In this tutorial, we applied the 4-step mental framework:
- Identify inputs: We used
vUvfor position anduTimefor animation. - Shaping functions: We used
smoothstep()to create soft edges for our ring and arc mask. - Distance fields: We calculated
distance()from the center to create our circular rainbow shape. - Mixing/layering: We multiplied our color by the rainbow mask to composite the final image.
The vertex shader was minimal. All the creative work happened in the fragment shader, where each pixel asked, "How far am I from the center? Am I within the rainbow band? Am I above the horizon? What color should I be?"
That's the shader mindset. Every pixel runs the same code, but each one gets a different answer based on its position.