Skip to content

Shader Effects Recipes

A Three.js guide for coding agents. Also covers shader effects, custom shader material, ShaderMaterial recipes, raging sea, animated water shader, ocean waves, and 26 more.

Show all 32 aliases

shader effects, custom shader material, ShaderMaterial recipes, raging sea, animated water shader, ocean waves, wave displacement, onBeforeCompile, modified built-in material, shader chunks, customDepthMaterial, shadow broken after vertex displacement, coffee smoke, smoke shader, perlin noise texture, hologram shader, fresnel effect, scanlines, fireworks particles, point size attenuation, particle explosion animation, remap function GLSL, wobbly sphere, simplex noise displacement, recompute normals in shader, neighbour technique normals, sliced model, discard fragments by angle, procedural terrain, elevation based coloring, how do I animate vertices in a shader, how do I fix shadows on a displaced mesh

Shared foundations

These conventions repeat across every effect below.

  • End every custom fragment shader with the Three.js chunks so tone mapping and color space match the renderer. Without them, colors are off:
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
  • Declare uniforms with new THREE.Uniform(value), and update via material.uniforms.uTime.value = elapsedTime in the tick
  • Attributes (position, uv, normal) are read only in GLSL. Copy into a local (vec3 newPosition = position;) before modifying
  • The transparency trio for glowy or smoky things: transparent: true, depthWrite: false (stops the mesh occluding itself and other transparent surfaces), and often blending: THREE.AdditiveBlending for light-like effects
  • Move a value from vertex to fragment with a varying. Varyings are interpolated between vertices, so a normalized vector arriving in the fragment is no longer length 1: normalize(vNormal) again in the fragment shader
  • Recenter a 0..1 random or noise value with - 0.5 so offsets go both ways instead of drifting in one direction
  • Ease a 0..1 progress with pow(p, 3.0) (slow start) or 1.0 - pow(1.0 - p, 3.0) (fast start, ease out). Both only work because the range is 0..1
  • Share GLSL helpers via file includes (#include ../includes/rotate2D.glsl, works with vite-plugin-glsl), one function per file
  • Prefer a tiling noise TEXTURE over a noise FUNCTION when possible: sampling a 128px repeating Perlin image is far cheaper than running cnoise per fragment. Set wrapS = wrapT = THREE.RepeatWrapping. Pack up to 4 different noises in the rgba channels of one image

Raging sea: sin plus noise displacement, color by elevation

Core idea: displace a highly subdivided plane (512x512) in the vertex shader. Big waves are two multiplied sines, small waves are layered 3D Perlin noise where the third dimension is time. Color mixes by elevation passed as a varying.

// vertex
float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed)
                * sin(modelPosition.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed)
                * uBigWavesElevation;

for(float i = 1.0; i <= uSmallIterations; i++)
{
    elevation -= abs(cnoise(vec3(modelPosition.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
}
modelPosition.y += elevation;
vElevation = elevation;
// fragment
float mixStrength = (vElevation + uColorOffset) * uColorMultiplier;
vec3 color = mix(uDepthColor, uSurfaceColor, mixStrength);
  • -= abs(noise) is the wave-shape trick: plain noise gives smooth hills, abs() gives sharp features, and subtracting flips them into rounded troughs with high crests, which is what real waves look like
  • The octave loop multiplies frequency by i and divides amplitude by i each iteration for chaotic multi-frequency detail. GLSL for loops need a float counter
  • Elevation is roughly -0.2..0.2, useless as a mix factor directly. uColorOffset and uColorMultiplier remap it into 0..1 without touching the vertex shader
  • Colors as lil-gui tweaks need a plain debugObject holding hex strings, with onChange calling uniform.value.set(...)

Modified built-in materials: onBeforeCompile

Core idea: keep everything MeshStandardMaterial gives you (lights, env maps, textures, shadows) and inject vertex animation by string-replacing shader chunks before compile.

const customUniforms = { uTime: { value: 0 } } // module scope, updated in tick

material.onBeforeCompile = (shader) =>
{
    shader.uniforms.uTime = customUniforms.uTime
    shader.vertexShader = shader.vertexShader.replace('#include <common>', `
        #include <common>
        uniform float uTime;
        mat2 get2dRotateMatrix(float _angle)
        {
            return mat2(cos(_angle), - sin(_angle), sin(_angle), cos(_angle));
        }
    `)
    shader.vertexShader = shader.vertexShader.replace('#include <beginnormal_vertex>', `
        #include <beginnormal_vertex>
        float angle = (position.y + uTime) * 0.9;
        mat2 rotateMatrix = get2dRotateMatrix(angle);
        objectNormal.xz = rotateMatrix * objectNormal.xz;
    `)
    shader.vertexShader = shader.vertexShader.replace('#include <begin_vertex>', `
        #include <begin_vertex>
        transformed.xz = rotateMatrix * transformed.xz;
    `)
}

Key chunks (source in node_modules/three/src/renderers/shaders/ShaderChunk/):

  • common: present in all shaders, outside main(), the place for uniforms and helper functions
  • begin_vertex: creates transformed, the vertex position to displace
  • beginnormal_vertex: creates objectNormal. Rotate or transform the normal the same way as the position or lighting and core shadows are wrong. It runs BEFORE begin_vertex, so declare shared variables there and reuse them (declaring twice gives a redefinition error since all chunks merge into one program)

Gotchas:

  • Uniforms are not reachable from the material afterwards. Keep a module-scope customUniforms object, assign its entries into shader.uniforms, and update it in tick
  • Drop shadows come from a separate MeshDepthMaterial render that knows nothing about your displacement. Fix: create new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }), apply the SAME onBeforeCompile vertex patches to it (no normal patch needed, depth does not use normals), and assign it to mesh.customDepthMaterial. Traverse multi-mesh models to set it everywhere

Coffee smoke: perlin-textured alpha plus uv distortion

Core idea: a tall subdivided plane, bottom pinned at the mug. Fragment shader scrolls a tiling Perlin texture through the alpha channel. Vertex shader twists and sways using values sampled from the SAME texture.

// fragment
vec2 smokeUv = vUv;
smokeUv.x *= 0.5;
smokeUv.y *= 0.3;
smokeUv.y -= uTime * 0.03;               // scroll upward

float smoke = texture(uPerlinTexture, smokeUv).r;
smoke = smoothstep(0.4, 1.0, smoke);      // remap: kill values below 0.4 for sporadic puffs

// fade all four edges by multiplying smoothsteps on the ORIGINAL vUv
smoke *= smoothstep(0.0, 0.1, vUv.x);
smoke *= smoothstep(1.0, 0.9, vUv.x);     // inverted limits invert the edge
smoke *= smoothstep(0.0, 0.1, vUv.y);
smoke *= smoothstep(1.0, 0.4, vUv.y);

gl_FragColor = vec4(0.6, 0.3, 0.2, smoke);
// vertex: twist by elevation, sway with wind
float twistPerlin = texture(uPerlinTexture, vec2(0.5, uv.y * 0.2 - uTime * 0.005)).r;
newPosition.xz = rotate2D(newPosition.xz, twistPerlin * 10.0);

vec2 windOffset = vec2(
    texture(uPerlinTexture, vec2(0.25, uTime * 0.01)).r - 0.5,
    texture(uPerlinTexture, vec2(0.75, uTime * 0.01)).r - 0.5
);
newPosition.xz += windOffset * pow(uv.y, 2.0) * 10.0;
  • Sampling the texture along a fixed column (vec2(0.5, uv.y)) turns a 2D texture into a cheap 1D noise. Use DIFFERENT columns (0.25 and 0.75) for wind x and z or they move diagonally in lockstep
  • pow(uv.y, 2.0) anchors the smoke base: zero offset at the bottom, growing fast near the top
  • Sampling a texture in the VERTEX shader is fine and cheap
  • Geometry transforms (geometry.translate(0, 0.5, 0) then scale) put the pivot at the base so vertex math stays simple
  • Material needs transparent, side: THREE.DoubleSide, depthWrite: false
  • To debug vertex motion, temporarily set gl_FragColor to solid red and turn wireframe on

Hologram: fresnel, scanlines, glitch

Core idea: world-space scanline stripes on the alpha, a fresnel term that brightens edges, additive blending, and a vertex glitch that ripples upward in sporadic bursts.

// fragment
vec3 normal = normalize(vNormal);
if(!gl_FrontFacing) normal *= - 1.0;      // flip normals on back faces for DoubleSide

// scanlines: repeating gradient in world space, sharpened
float stripes = mod((vPosition.y - uTime * 0.02) * 20.0, 1.0);
stripes = pow(stripes, 3.0);

// fresnel: bright where view direction is perpendicular to normal
vec3 viewDirection = normalize(vPosition - cameraPosition);
float fresnel = dot(viewDirection, normal) + 1.0;  // shift -1..1 to 0..2, facing = 0
fresnel = pow(fresnel, 2.0);

float falloff = smoothstep(0.8, 0.0, fresnel);     // fade the extreme rim

float holographic = stripes * fresnel;
holographic += fresnel * 1.25;
holographic *= falloff;

gl_FragColor = vec4(uColor, holographic);
// vertex glitch, applied to modelPosition before view/projection
float glitchTime = uTime - modelPosition.y;         // wave travels bottom to top
float glitchStrength = sin(glitchTime) + sin(glitchTime * 3.45) + sin(glitchTime * 8.76);
glitchStrength /= 3.0;
glitchStrength = smoothstep(0.3, 1.0, glitchStrength);  // mostly off, occasional bursts
glitchStrength *= 0.25;
modelPosition.x += (random2D(modelPosition.xz + uTime) - 0.5) * glitchStrength;
modelPosition.z += (random2D(modelPosition.zx + uTime) - 0.5) * glitchStrength;
  • Base stripes on modelPosition (world space) so the pattern stays fixed while the object rotates. Use local position if you want the pattern to travel with the object
  • Transform normals with modelMatrix * vec4(normal, 0.0): the w = 0.0 skips translation, because a normal is a direction, not a position
  • cameraPosition is a built-in uniform, view direction is normalize(vPosition - cameraPosition), dot of the two gives -1 facing camera, 0 at the silhouette. Adding 1.0 makes it a usable 0..2 rim value
  • GLSL random: fract(sin(dot(v, vec2(12.9898, 78.233))) * 43758.5453123), seed with position PLUS time or the glitch freezes when the object stops
  • Summing sines at unrelated frequencies (1.0, 3.45, 8.76) then dividing by the count fakes aperiodic randomness for burst timing
  • Material: transparent, side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending

Fireworks: phased particle animation from one progress value

Core idea: a THREE.Points cloud with positions on a jittered sphere. GSAP animates a single uProgress uniform 0 to 1 linearly. The vertex shader remaps that one value into overlapping phases: explode, fall, scale up/down, twinkle. Dispose on complete.

float remap(float value, float originMin, float originMax, float destinationMin, float destinationMax)
{
    return destinationMin + (value - originMin) * (destinationMax - destinationMin) / (originMax - originMin);
}

void main()
{
    float progress = uProgress * aTimeMultiplier;  // per-particle speed (attribute 1..2)
    vec3 newPosition = position;

    // Exploding: 0.0 -> 0.1
    float explodingProgress = clamp(remap(progress, 0.0, 0.1, 0.0, 1.0), 0.0, 1.0);
    explodingProgress = 1.0 - pow(1.0 - explodingProgress, 3.0);  // ease out
    newPosition = mix(vec3(0.0), newPosition, explodingProgress);

    // Falling: 0.1 -> 1.0
    float fallingProgress = clamp(remap(progress, 0.1, 1.0, 0.0, 1.0), 0.0, 1.0);
    fallingProgress = 1.0 - pow(1.0 - fallingProgress, 3.0);
    newPosition.y -= fallingProgress * 0.2;

    // Scaling: fast open, slow close, min of the two ramps
    float sizeOpeningProgress = remap(progress, 0.0, 0.125, 0.0, 1.0);
    float sizeClosingProgress = remap(progress, 0.125, 1.0, 1.0, 0.0);
    float sizeProgress = clamp(min(sizeOpeningProgress, sizeClosingProgress), 0.0, 1.0);

    // Twinkling: sine flicker gated by its own window, inverted so idle = visible
    float twinklingProgress = clamp(remap(progress, 0.2, 0.8, 0.0, 1.0), 0.0, 1.0);
    float sizeTwinkling = sin(progress * 30.0) * 0.5 + 0.5;
    sizeTwinkling = 1.0 - sizeTwinkling * twinklingProgress;

    vec4 modelPosition = modelMatrix * vec4(newPosition, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    gl_Position = projectionMatrix * viewPosition;

    gl_PointSize = uSize * uResolution.y * aSize * sizeProgress * sizeTwinkling;
    gl_PointSize *= 1.0 / - viewPosition.z;        // perspective attenuation

    if(gl_PointSize < 1.0)
        gl_Position = vec4(9999.9);                // Windows clamps points to min 1px, hide off-clip
}
  • The remap-clamp-ease pattern is the whole trick: each phase gets its own window of the shared progress, clamp pins it outside the window, min() of an opening and a closing ramp gives an up-then-down envelope
  • Correct point sizing: uSize * uResolution.y where uResolution is render size TIMES pixel ratio, kept in a shared Vector2 updated on resize. This makes particles scale with render height (Three.js FOV is vertical) and stay identical across pixel ratios. Some GPUs cap gl_PointSize at 64
  • Particle textures: sample with gl_PointCoord not uv, set texture.flipY = false, take only the .r channel of grayscale sprites as alpha
  • Sphere distribution: new THREE.Spherical(radius * (0.75 + Math.random() * 0.25), Math.random() * Math.PI, Math.random() * Math.PI * 2) then vector3.setFromSpherical(spherical). Jittering the radius breaks the too-perfect shell
  • Per-particle attributes: aSize (random 0..1) and aTimeMultiplier (1..2) with item size 1. The multiplier desynchronizes lifespans while guaranteeing every particle finishes BEFORE the dispose
  • Cleanup on GSAP onComplete: scene.remove(points); geometry.dispose(); material.dispose(). Keep textures alive for reuse
  • Drive with gsap.to(material.uniforms.uProgress, { value: 1, duration: 3, ease: 'linear' }): linear, because easing lives in the shader

Wobbly sphere: CustomShaderMaterial plus 4D simplex noise

Core idea: keep the full MeshPhysicalMaterial (transmission, metalness, shadows) and displace vertices along their normals with warped 4D simplex noise, using the three-custom-shader-material (CSM) library instead of raw onBeforeCompile.

import CustomShaderMaterial from 'three-custom-shader-material/vanilla'
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js'

let geometry = new THREE.IcosahedronGeometry(2.5, 50)
geometry = mergeVertices(geometry)   // tangents need an indexed geometry
geometry.computeTangents()

const uniforms = { uTime: new THREE.Uniform(0), /* frequencies, strengths, colors */ }

const material = new CustomShaderMaterial({
    baseMaterial: THREE.MeshPhysicalMaterial,
    vertexShader, fragmentShader, uniforms,
    metalness: 0, roughness: 0.5, transmission: 0, ior: 1.5, thickness: 1.5,
})
const depthMaterial = new CustomShaderMaterial({
    baseMaterial: THREE.MeshDepthMaterial,
    vertexShader, uniforms,                       // same vertex shader, no fragment
    depthPacking: THREE.RGBADepthPacking,
})
mesh.customDepthMaterial = depthMaterial

CSM output variables: write csm_Position, csm_Normal in the vertex shader; csm_DiffuseColor (pre-lighting color), csm_Metalness, csm_Roughness, csm_FragColor (final override, loses shading) in the fragment shader.

// vertex
float getWobble(vec3 position)
{
    vec3 warpedPosition = position;   // noise-warp the noise input for organic motion
    warpedPosition += simplexNoise4d(vec4(position * uWarpPositionFrequency, uTime * uWarpTimeFrequency)) * uWarpStrength;
    return simplexNoise4d(vec4(warpedPosition * uPositionFrequency, uTime * uTimeFrequency)) * uStrength;
}

void main()
{
    vec3 biTangent = cross(normal, tangent.xyz);   // attribute vec4 tangent

    float shift = 0.01;
    vec3 positionA = csm_Position + tangent.xyz * shift;
    vec3 positionB = csm_Position + biTangent * shift;

    float wobble = getWobble(csm_Position);
    csm_Position += wobble * normal;
    positionA    += getWobble(positionA) * normal;
    positionB    += getWobble(positionB) * normal;

    vec3 toA = normalize(positionA - csm_Position);
    vec3 toB = normalize(positionB - csm_Position);
    csm_Normal = cross(toA, toB);

    vWobble = wobble / uStrength;      // renormalize to -1..1 for the fragment
}
// fragment
float colorMix = smoothstep(- 1.0, 1.0, vWobble);
csm_DiffuseColor.rgb = mix(uColorA, uColorB, colorMix);
csm_Roughness = 1.0 - colorMix;                    // shiny tips
// or hard mirror tips: csm_Metalness = step(0.25, vWobble); csm_Roughness = 1.0 - csm_Metalness;
  • Neighbour-normal recomputation for ARBITRARY meshes: neighbours are found along the tangent and bitangent (bitangent = cross(normal, tangent)), both displaced with the same function, then csm_Normal = cross(toA, toB). This works on imported models, unlike grid-based neighbour math
  • computeTangents() requires index, position, normal, uv. Non-indexed geometry: run mergeVertices() first (expensive, cost grows steeply with vertex count, one-time at startup)
  • 4D noise: xyz = position, w = time. Warping (noise fed into noise) beats stacking octaves when you want blobby organic motion instead of waves
  • One shared uniforms object feeds both the material and the depth material so shadows animate identically
  • Works on GLTF models directly: assign material and customDepthMaterial to the loaded mesh (imported geometry is often already indexed)

Sliced model: discard by radial angle

Core idea: cut a cake slice out of a mesh by discarding fragments whose polar angle falls inside a start + arc window, show the interior with DoubleSide, paint back faces a flat color, and patch the depth material so shadows are sliced too.

// fragment (CSM on MeshStandardMaterial)
float angle = atan(vPosition.y, vPosition.x);  // vPosition = csm_Position.xyz varying, local space
angle -= uSliceStart;
angle = mod(angle, PI * 2.0);   // GLSL mod wraps negatives up into 0..2PI (unlike JS %)

if(angle > 0.0 && angle < uSliceArc)
    discard;

float csm_Slice;   // dummy declaration activates the patchMap
  • atan(y, x) returns -PI..PI, so a start+arc window that crosses the seam fails a naive range check. Fix: rotate the frame by subtracting uSliceStart, then mod(angle, 2.0 * PI) folds the negative branch into 0..2PI, and the test is simply 0 < angle < uSliceArc
  • PI already exists in built-in materials via the common chunk, no #define needed
  • Flat interior color: real cap geometry is too expensive to rebuild per frame, so set side: THREE.DoubleSide and color back faces (!gl_FrontFacing) a uniform color. In CSM you cannot use csm_FragColor for only one branch (its mere presence overrides the whole output), so inject via patchMap:
const patchMap = {
    csm_Slice: {
        '#include <colorspace_fragment>': `
            #include <colorspace_fragment>
            if(!gl_FrontFacing)
                gl_FragColor = vec4(0.75, 0.15, 0.3, 1.0);
        `
    }
}
// activate by declaring `float csm_Slice;` anywhere in the shader
  • Shadow fix: duplicate the material as baseMaterial: THREE.MeshDepthMaterial with depthPacking: THREE.RGBADepthPacking, same vertexShader, same FRAGMENT shader this time (the discard must run in the depth pass), same uniforms and patchMap. Assign to customDepthMaterial of the sliced mesh only
  • Apply the sliced material selectively by traversing the model and matching child.name
  • When debugging positions as colors, temporarily disable tone mapping and set renderer.outputColorSpace = THREE.LinearSRGBColorSpace, otherwise the debug colors lie

Procedural terrain: CSM displacement, octaves, elevation coloring

Core idea: a 500x500 plane rotated flat AT THE GEOMETRY level (geometry.rotateX(- Math.PI * 0.5)) so shader math ignores mesh transforms. Vertex shader builds elevation from warped simplex octaves, recomputes normals via grid neighbours, and the fragment shader banded-colors by height plus slope.

// vertex
float getElevation(vec2 position)
{
    vec2 warpedPosition = position;
    warpedPosition += uTime * 0.2;   // terrain drifts
    warpedPosition += simplexNoise2d(warpedPosition * uPositionFrequency * uWarpFrequency) * uWarpStrength;

    float elevation = 0.0;
    elevation += simplexNoise2d(warpedPosition * uPositionFrequency      ) / 2.0;
    elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 2.0) / 4.0;
    elevation += simplexNoise2d(warpedPosition * uPositionFrequency * 4.0) / 8.0;

    float elevationSign = sign(elevation);
    elevation = pow(abs(elevation), 2.0) * elevationSign;  // flatten near sea level, keep crevices
    elevation *= uStrength;
    return elevation;
}

void main()
{
    float shift = 0.01;
    vec3 positionA = position.xyz + vec3(shift, 0.0, 0.0);
    vec3 positionB = position.xyz + vec3(0.0, 0.0, - shift);  // NEGATIVE z or the normal flips

    float elevation = getElevation(csm_Position.xz);
    csm_Position.y += elevation;
    positionA.y    += getElevation(positionA.xz);
    positionB.y    += getElevation(positionB.xz);

    vec3 toA = normalize(positionA - csm_Position);
    vec3 toB = normalize(positionB - csm_Position);
    csm_Normal = cross(toA, toB);

    vPosition = csm_Position;
    vPosition.xz += uTime * 0.2;     // keep fragment noise in sync with the drifting terrain
    vUpDot = dot(csm_Normal, vec3(0.0, 1.0, 0.0));  // slope, computed once per vertex
}
// fragment: bottom-up color bands, each mix overwrites the last
vec3 color = vec3(1.0);
float surfaceWaterMix = smoothstep(- 1.0, - 0.1, vPosition.y);          // smooth depth gradient
color = mix(uColorWaterDeep, uColorWaterSurface, surfaceWaterMix);
color = mix(color, uColorSand,  step(- 0.1,  vPosition.y));             // hard shoreline
color = mix(color, uColorGrass, step(- 0.06, vPosition.y));

float rockMix = 1.0 - step(0.8, vUpDot);        // steep faces only
rockMix *= step(- 0.06, vPosition.y);           // not underwater
color = mix(color, uColorRock, rockMix);

float snowThreshold = 0.45 + simplexNoise2d(vPosition.xz * 15.0) * 0.1; // ragged snow line
color = mix(color, uColorSnow, step(snowThreshold, vPosition.y));

csm_DiffuseColor = vec4(color, 1.0);
  • Octaves: frequency x2, amplitude /2 per layer, and keep the total under 1.0 so the plateau pow behaves
  • pow on signed values is the classic terrain bug: even exponents erase negatives, odd exponents double-apply the sign trick. The robust form is pow(abs(e), n) * sign(e)
  • Since csm_Normal replaces the attribute entirely, delete unused attributes: geometry.deleteAttribute('normal') and deleteAttribute('uv')
  • Use the LOWEST-dimensional noise that works (2D here), noise cost grows with dimensions
  • Same shared-uniforms depth material recipe as the wobble: MeshDepthMaterial base, same vertex shader, RGBADepthPacking, on customDepthMaterial
  • Water surface is not a shader: a plain plane at y = -0.1 with MeshPhysicalMaterial({ transmission: 1, roughness: 0.3 }) refracts and blurs the terrain below for free
  • Surrounding board: boolean-subtract two boxes with three-bvh-csg (evaluator.evaluate(fill, hole, SUBTRACTION)), call geometry.clearGroups() to drop per-brush materials, and updateMatrixWorld() manually after transforming a Brush that never renders

Common mistakes

  • Forgetting #include <colorspace_fragment> at the end of a fragment shader, then wondering why colors look washed out
  • Setting alpha in the shader without transparent: true on the material
  • Transparent surfaces occluding themselves: missing depthWrite: false
  • Displacing vertices without touching normals, giving flat-looking or wrongly lit results. Recompute via neighbours (grid offsets for planes, tangent/bitangent for arbitrary meshes)
  • Displacing vertices and not patching the depth material, so shadows show the undeformed mesh. Every vertex-displacement recipe needs the customDepthMaterial + RGBADepthPacking pair
  • Declaring the same variable in two onBeforeCompile chunk replacements: chunks merge into one program, declare once in the earliest chunk
  • Transforming a normal with w = 1.0, which applies translation and bends every normal toward the offset. Directions use w = 0.0
  • Trusting an interpolated varying normal to be unit length, causing a faint grid artifact. Re-normalize in the fragment shader
  • Using count - 1 style range assumptions from JS on GLSL mod: GLSL mod wraps negatives upward (mod(-0.25, 1.0) = 0.75), JS % does not
  • Sizing points without resolution and pixel ratio, so particles differ across screens, and forgetting the Windows 1px minimum clamp (hide with gl_Position = vec4(9999.9))
  • Calling computeTangents() on a non-indexed geometry (throws): mergeVertices() first
  • Sampling the same noise-texture line for two axes, producing perfectly diagonal motion. Offset the sample coordinates per axis
  • Running a Perlin FUNCTION per fragment when a tiny tiling noise texture would do: the texture is the performance-safe default
  • Easing a 0..1 progress with pow and getting the wrong end fast: pow(p, n) eases in, 1.0 - pow(1.0 - p, n) eases out
  • Remapping a phase window without clamping, so the animation keeps running past the window bounds
  • Forgetting to dispose geometry and material when removing short-lived objects like fireworks, leaking GPU memory per click

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: "shader-effects-recipes" })
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