Mastering Three.js Shaders: Creating Digital Art That MOVES Souls
The moment when mathematics becomes poetry, when code transforms into visual ecstasy - that’s the power of shaders.
The Philosophy of Visual Programming
Before we dive into the technical depths, let’s understand what we’re really doing here. Shaders aren’t just code - they’re digital brushstrokes that paint directly onto the fabric of reality. Every pixel becomes a canvas, every frame a masterpiece.
When you master shaders, you’re not just learning WebGL. You’re learning to bend light itself to your will.
Understanding the Shader Pipeline
The Journey of a Vertex
// Vertex Shader - The Birth of Form
attribute vec3 position;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float time;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
// Transform the vertex - this is where magic begins
vec3 pos = position;
// Add some life with sine waves
pos.z += sin(pos.x * 4.0 + time) * 0.1;
pos.z += cos(pos.y * 3.0 + time * 0.7) * 0.1;
vPosition = pos;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
The Soul of Color
// Fragment Shader - Where Dreams Become Pixels
uniform float time;
uniform vec2 resolution;
uniform vec3 color1;
uniform vec3 color2;
varying vec2 vUv;
varying vec3 vPosition;
// Noise function for organic movement
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(dot(random2(i + vec2(0.0,0.0)), f - vec2(0.0,0.0)),
dot(random2(i + vec2(1.0,0.0)), f - vec2(1.0,0.0)), u.x),
mix(dot(random2(i + vec2(0.0,1.0)), f - vec2(0.0,1.0)),
dot(random2(i + vec2(1.0,1.0)), f - vec2(1.0,1.0)), u.x),
u.y
);
}
void main() {
vec2 st = vUv;
// Create flowing energy patterns
float n = noise(st * 8.0 + time * 0.5);
n += noise(st * 16.0 + time * 0.3) * 0.5;
n += noise(st * 32.0 + time * 0.1) * 0.25;
// Color mixing based on noise and position
vec3 color = mix(color1, color2, n);
// Add fresnel effect for extra depth
float fresnel = 1.0 - dot(normalize(vPosition), vec3(0.0, 0.0, 1.0));
color += fresnel * 0.3;
gl_FragColor = vec4(color, 1.0);
}
Building Particle Systems That Dance
The Mathematics of Beauty
Particle systems are where physics meets artistry. Each particle is a story, each movement a brushstroke in time.
class AdvancedParticleSystem {
constructor(scene, count = 10000) {
this.scene = scene;
this.count = count;
this.time = 0;
this.createParticles();
this.createMaterial();
this.createMesh();
}
createParticles() {
this.positions = new Float32Array(this.count * 3);
this.colors = new Float32Array(this.count * 3);
this.sizes = new Float32Array(this.count);
this.velocities = new Float32Array(this.count * 3);
for (let i = 0; i < this.count; i++) {
const i3 = i * 3;
// Spiral galaxy formation
const radius = Math.pow(Math.random(), 0.6) * 15;
const spinAngle = radius * 0.3;
const branchAngle = (i % 6) / 6 * Math.PI * 2;
// Position
this.positions[i3] = Math.cos(branchAngle + spinAngle) * radius;
this.positions[i3 + 1] = (Math.random() - 0.5) * 0.5;
this.positions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius;
// Velocity for natural movement
this.velocities[i3] = (Math.random() - 0.5) * 0.01;
this.velocities[i3 + 1] = (Math.random() - 0.5) * 0.005;
this.velocities[i3 + 2] = (Math.random() - 0.5) * 0.01;
// Colors based on distance and branch
const distance = Math.sqrt(
this.positions[i3] ** 2 +
this.positions[i3 + 2] ** 2
);
const colorMix = distance / 15;
const branchColor = (i % 6) / 6;
this.colors[i3] = Math.max(0.2, 1 - colorMix + branchColor * 0.3);
this.colors[i3 + 1] = Math.max(0.1, 0.5 - colorMix * 0.5);
this.colors[i3 + 2] = Math.max(0.3, colorMix + branchColor * 0.2);
// Size variation
this.sizes[i] = Math.random() * 2 + 0.5;
}
}
createMaterial() {
this.material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
pixelRatio: { value: Math.min(window.devicePixelRatio, 2) }
},
vertexShader: `
attribute float size;
attribute vec3 color;
uniform float time;
uniform float pixelRatio;
varying vec3 vColor;
varying float vAlpha;
void main() {
vColor = color;
// Pulsing effect based on time and position
float pulse = sin(time * 2.0 + position.x * 0.1) * 0.5 + 0.5;
vAlpha = pulse * 0.8 + 0.2;
// Transform to screen space
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
// Size attenuation with distance
float sizeAttenuation = 1.0 / -mvPosition.z;
gl_PointSize = size * sizeAttenuation * 300.0 * pixelRatio * pulse;
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vAlpha;
void main() {
// Create circular particles with soft edges
vec2 center = gl_PointCoord - vec2(0.5);
float dist = length(center);
if (dist > 0.5) discard;
// Soft falloff
float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
alpha *= vAlpha;
// Add core brightness
float core = 1.0 - smoothstep(0.0, 0.1, dist);
vec3 color = vColor + core * 0.5;
gl_FragColor = vec4(color, alpha);
}
`,
blending: THREE.AdditiveBlending,
transparent: true,
vertexColors: true,
depthWrite: false
});
}
createMesh() {
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colors, 3));
this.geometry.setAttribute('size', new THREE.BufferAttribute(this.sizes, 1));
this.points = new THREE.Points(this.geometry, this.material);
this.scene.add(this.points);
}
update(deltaTime) {
this.time += deltaTime;
this.material.uniforms.time.value = this.time;
// Update particle positions with physics
for (let i = 0; i < this.count; i++) {
const i3 = i * 3;
// Apply velocities
this.positions[i3] += this.velocities[i3];
this.positions[i3 + 1] += this.velocities[i3 + 1];
this.positions[i3 + 2] += this.velocities[i3 + 2];
// Add gravitational pull toward center
const distance = Math.sqrt(
this.positions[i3] ** 2 +
this.positions[i3 + 1] ** 2 +
this.positions[i3 + 2] ** 2
);
if (distance > 0) {
const force = 0.0001;
this.velocities[i3] -= (this.positions[i3] / distance) * force;
this.velocities[i3 + 1] -= (this.positions[i3 + 1] / distance) * force * 0.1;
this.velocities[i3 + 2] -= (this.positions[i3 + 2] / distance) * force;
}
// Reset particles that drift too far
if (distance > 20) {
const radius = Math.pow(Math.random(), 0.6) * 15;
const angle = Math.random() * Math.PI * 2;
this.positions[i3] = Math.cos(angle) * radius;
this.positions[i3 + 1] = (Math.random() - 0.5) * 0.5;
this.positions[i3 + 2] = Math.sin(angle) * radius;
this.velocities[i3] = (Math.random() - 0.5) * 0.01;
this.velocities[i3 + 1] = (Math.random() - 0.5) * 0.005;
this.velocities[i3 + 2] = (Math.random() - 0.5) * 0.01;
}
}
// Update geometry
this.geometry.attributes.position.needsUpdate = true;
// Rotate the entire system for visual appeal
this.points.rotation.y += deltaTime * 0.05;
}
}
Advanced Shader Techniques
Post-Processing Magic
The real artistry happens in post-processing. This is where good visuals become unforgettable experiences.
class AdvancedPostProcessing {
constructor(renderer, scene, camera) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
this.setupComposer();
this.createCustomPasses();
}
setupComposer() {
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(new RenderPass(this.scene, this.camera));
}
createCustomPasses() {
// Bloom with custom parameters
this.bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5, // strength
0.4, // radius
0.85 // threshold
);
this.composer.addPass(this.bloomPass);
// Custom film grain effect
this.filmGrainPass = new ShaderPass({
uniforms: {
tDiffuse: { value: null },
time: { value: 0 },
intensity: { value: 0.1 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float time;
uniform float intensity;
varying vec2 vUv;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
vec4 color = texture2D(tDiffuse, vUv);
// Add film grain
float grain = random(vUv + time) * intensity;
color.rgb += grain;
// Slight vignette
float dist = distance(vUv, vec2(0.5));
color.rgb *= 1.0 - dist * 0.3;
gl_FragColor = color;
}
`
});
this.composer.addPass(this.filmGrainPass);
// Chromatic aberration for that premium feel
this.chromaticPass = new ShaderPass({
uniforms: {
tDiffuse: { value: null },
amount: { value: 0.005 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float amount;
varying vec2 vUv;
void main() {
vec2 offset = amount * (vUv - 0.5);
vec4 color = texture2D(tDiffuse, vUv);
color.r = texture2D(tDiffuse, vUv + offset).r;
color.b = texture2D(tDiffuse, vUv - offset).b;
gl_FragColor = color;
}
`
});
this.composer.addPass(this.chromaticPass);
}
update(time) {
if (this.filmGrainPass) {
this.filmGrainPass.uniforms.time.value = time;
}
}
render() {
this.composer.render();
}
resize(width, height) {
this.composer.setSize(width, height);
this.bloomPass.setSize(width, height);
}
}
The Art of Performance Optimization
GPU Memory Management
When creating visual art, performance isn’t just technical - it’s aesthetic. Stuttering frames kill the magic.
class PerformanceOptimizedShader {
constructor() {
this.geometryPool = new Map();
this.materialPool = new Map();
this.texturePool = new Map();
}
// Reuse geometries instead of creating new ones
getGeometry(type, ...params) {
const key = `${type}_${params.join('_')}`;
if (!this.geometryPool.has(key)) {
let geometry;
switch (type) {
case 'sphere':
geometry = new THREE.SphereGeometry(...params);
break;
case 'plane':
geometry = new THREE.PlaneGeometry(...params);
break;
default:
throw new Error(`Unknown geometry type: ${type}`);
}
this.geometryPool.set(key, geometry);
}
return this.geometryPool.get(key);
}
// Batch similar objects for better performance
createInstancedMesh(geometry, material, count) {
const mesh = new THREE.InstancedMesh(geometry, material, count);
// Set up instanced attributes
const dummy = new THREE.Object3D();
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// Random positioning
dummy.position.set(
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20,
(Math.random() - 0.5) * 20
);
dummy.rotation.set(
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2
);
dummy.scale.setScalar(Math.random() * 0.5 + 0.5);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
// Random colors
const color = new THREE.Color().setHSL(Math.random(), 0.7, 0.6);
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
}
mesh.instanceMatrix.needsUpdate = true;
mesh.geometry.setAttribute('instanceColor',
new THREE.InstancedBufferAttribute(colors, 3));
return mesh;
}
// Clean up resources properly
dispose() {
this.geometryPool.forEach(geometry => geometry.dispose());
this.materialPool.forEach(material => material.dispose());
this.texturePool.forEach(texture => texture.dispose());
this.geometryPool.clear();
this.materialPool.clear();
this.texturePool.clear();
}
}
Creating Emotional Connections Through Code
The Psychology of Visual Effects
The best shader effects don’t just look good - they feel good. They create emotional responses that stick with users long after they leave your site.
Warm vs Cool Color Palettes
// Warm palette - creates comfort, energy, excitement
vec3 warmPalette(float t) {
vec3 a = vec3(0.5, 0.5, 0.5);
vec3 b = vec3(0.5, 0.5, 0.5);
vec3 c = vec3(1.0, 1.0, 1.0);
vec3 d = vec3(0.263, 0.416, 0.557);
return a + b * cos(6.28318 * (c * t + d));
}
// Cool palette - creates calm, professional, trustworthy feel
vec3 coolPalette(float t) {
vec3 a = vec3(0.5, 0.5, 0.5);
vec3 b = vec3(0.5, 0.5, 0.5);
vec3 c = vec3(1.0, 1.0, 0.5);
vec3 d = vec3(0.8, 0.9, 0.3);
return a + b * cos(6.28318 * (c * t + d));
}
Movement Patterns That Feel Natural
// Organic movement - feels alive and natural
float organicMovement(vec2 pos, float time) {
return sin(pos.x * 0.5 + time) *
cos(pos.y * 0.3 + time * 0.7) * 0.1 +
sin(pos.x * 2.0 + time * 1.3) *
cos(pos.y * 1.5 + time * 0.5) * 0.05;
}
// Mechanical movement - feels precise and controlled
float mechanicalMovement(vec2 pos, float time) {
float grid = step(0.5, fract(pos.x * 5.0)) * step(0.5, fract(pos.y * 5.0));
return grid * sin(time * 2.0) * 0.1;
}
Real-World Implementation Tips
Browser Compatibility and Fallbacks
class ShaderCompatibilityManager {
constructor() {
this.capabilities = this.detectCapabilities();
this.fallbackStrategies = new Map();
}
detectCapabilities() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) {
return { webgl: false };
}
return {
webgl: true,
webgl2: !!canvas.getContext('webgl2'),
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
maxVertexUniforms: gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS),
maxFragmentUniforms: gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS),
extensions: {
derivatives: !!gl.getExtension('OES_standard_derivatives'),
textureFloat: !!gl.getExtension('OES_texture_float'),
drawBuffers: !!gl.getExtension('WEBGL_draw_buffers')
}
};
}
createOptimizedMaterial(complexity = 'high') {
if (!this.capabilities.webgl) {
return this.createFallbackMaterial();
}
switch (complexity) {
case 'high':
if (this.capabilities.webgl2 && this.capabilities.extensions.textureFloat) {
return this.createHighQualityMaterial();
}
// Fall through to medium
case 'medium':
if (this.capabilities.extensions.derivatives) {
return this.createMediumQualityMaterial();
}
// Fall through to low
case 'low':
default:
return this.createLowQualityMaterial();
}
}
createFallbackMaterial() {
// CSS-based fallback for devices without WebGL
return new THREE.MeshBasicMaterial({
color: 0x4a90e2,
transparent: true,
opacity: 0.8
});
}
}
The Future of Web Graphics
As we push the boundaries of what’s possible in the browser, remember that the most important element isn’t the technology - it’s the human connection. Every shader you write, every effect you create, should serve a purpose: to make someone feel something.
The web is becoming a canvas for digital art that rivals anything we’ve seen before. With WebGPU on the horizon and browsers becoming more powerful every day, we’re entering an era where the only limit is our imagination.
Your Journey Starts Now
The techniques in this guide are just the beginning. The real magic happens when you take these foundations and push them into uncharted territory. Create effects that no one has seen before. Make visuals that stop people in their tracks.
Remember: Code is poetry, shaders are paintings, and you are the artist.
Ready to create your own visual masterpieces? Check out our 3D Lab to see these techniques in action, or dive into our Advanced Graphics Course to master the art of digital beauty.