Skip to content

Shader Particles and GPGPU

A Three.js guide for coding agents. Also covers shader particles, custom particles ShaderMaterial, Points custom shader, gl_PointSize, size attenuation formula, gl_PointCoord, and 29 more.

Show all 35 aliases

shader particles, custom particles ShaderMaterial, Points custom shader, gl_PointSize, size attenuation formula, gl_PointCoord, particle patterns, draw disc on particle, glowing star particle, animated galaxy shader, rotate particles in vertex shader, cursor reactive particles, canvas as data texture, CanvasTexture displacement, trail effect canvas, raycaster uv cursor, particle picture brightness, particle morphing, morph between shapes, dual position attributes, mix by uProgress, simplex noise stagger, GPGPU, GPUComputationRenderer, flow field particles, FBO ping pong, particles from texture position, uv reference attribute, particle lifetime respawn, how do I animate thousands of particles on the GPU, how do I make particles follow the mouse, how do I morph particles between models, particles disappear frustum culling, setIndex null duplicate particles, discard vs alpha particles

Points plus ShaderMaterial: the base recipe

  • PointsMaterial cannot be customized; swap it for ShaderMaterial on a THREE.Points. You lose size and sizeAttenuation and must reimplement both
  • Standard vertex shader: the usual model/view/projection chain plus gl_PointSize. Standard fragment tricks all read gl_PointCoord (per-particle UV, since varyings cannot describe a point's own surface)
  • position and color attributes are auto-declared by ShaderMaterial; do NOT rename them to aPosition/aColor or they clash with the prepended declarations. Custom attributes get an a prefix (aScale, aSize, aRandomness)

Size, pixel ratio, attenuation

gl_PointSize = uSize * aScale;                 // base size times per-particle random scale
gl_PointSize *= (1.0 / - viewPosition.z);      // size attenuation (perspective)
  • gl_PointSize is in fragments, so a retina screen halves apparent size. Either bake the ratio into the uniform (uSize: 30 * renderer.getPixelRatio(), which requires creating the material AFTER the renderer) or express size relative to the render height with a uResolution uniform: gl_PointSize = aSize * uSize * uResolution.y (uResolution includes pixel ratio, updated on resize)
  • The attenuation formula comes from Three's own point_vert chunk: gl_PointSize *= (scale / - mvPosition.z) with scale simplified to 1.0. viewPosition is the position after modelMatrix and viewMatrix

Point patterns via gl_PointCoord

// Sharp disc
float strength = 1.0 - step(0.5, distance(gl_PointCoord, vec2(0.5)));

// Glowing star (intense core, fast falloff)
float strength = distance(gl_PointCoord, vec2(0.5));
strength = 1.0 - strength;
strength = pow(strength, 10.0);
vec3 color = mix(vec3(0.0), vColor, strength);

// Disc with hard cutout, no transparency artifacts
if(length(gl_PointCoord - 0.5) > 0.5) discard;

// Point light alpha (small number division)
float alpha = 0.05 / length(gl_PointCoord - 0.5) - 0.1;   // subtract 2x the numerator
gl_FragColor = vec4(color, alpha);
  • discard avoids alpha-sorting bugs entirely; its performance cost is usually negligible
  • The 0.05 / d - 0.1 trick: dividing a small constant by distance gives a hot core; subtracting twice the constant forces alpha to hit 0 by d = 0.5 (the particle edge)
  • Pair glow alphas with blending: THREE.AdditiveBlending and depthWrite: false instead of transparent: true

Animated galaxy: rotation in the vertex shader

Animating attributes on the CPU dies at scale; rotate in the vertex shader with a uTime uniform instead.

vec4 modelPosition = modelMatrix * vec4(position, 1.0);

// Rotate around Y: closer to center spins faster
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;
modelPosition.x = cos(angle) * distanceToCenter;
modelPosition.z = sin(angle) * distanceToCenter;

// Apply per-particle randomness AFTER the rotation
modelPosition.xyz += aRandomness;
  • atan(x, z) recovers the current angle, length(xz) the radius; multiply cos/sin by the radius or every star collapses onto a unit cylinder
  • Speed proportional to 1.0 / distanceToCenter gives the differential spin that reads as a galaxy
  • Randomness gotcha: if the random offsets are baked into position, the rotation stretches them into visible ribbons over time. Keep position on the clean spiral, ship the jitter as an aRandomness vec3 attribute, and add it after rotating

Cursor-reactive particles: 2D canvas as displacement data

A grid of particles displaying a picture, pushed toward the camera where the cursor moves, with a fading trail. The trail persistence lives in a small offscreen 2D canvas used as a data texture.

Picture-driven particles

  • Geometry: PlaneGeometry(10, 10, 128, 128) used as points. Two cleanups matter: geometry.setIndex(null) (indexed grids draw up to 6 stacked particles per vertex, visible as extra brightness under AdditiveBlending) and geometry.deleteAttribute('normal')
  • In the vertex shader, sample a grayscale picture at the plane's uv and use it twice: size and brightness
float pictureIntensity = texture(uPictureTexture, uv).r;
gl_PointSize = 0.15 * pictureIntensity * uResolution.y;
vColor = vec3(pow(pictureIntensity, 2.0));   // pow crushes darks, keeps highlights

The canvas trail

  • Create a small canvas (128x128 is plenty), get its 2d context, fill black. Each frame:
    1. Fade: globalCompositeOperation = 'source-over', globalAlpha = 0.02, fillRect the whole canvas with black
    2. Draw: globalCompositeOperation = 'lighten' (additive-ish), globalAlpha = min(cursorSpeed * 0.1, 1), drawImage a grayscale glow image centered on the cursor
  • Glow sizing: glowSize = canvas.width * 0.25, and subtract glowSize * 0.5 from both coordinates so the glow centers on the cursor instead of hanging off its bottom right
  • Cursor speed alpha: keep a canvasCursorPrevious Vector2, distanceTo the current one BEFORE copying, so a stationary cursor stops feeding the trail
  • Load the glow with a plain new Image(); TextureLoader is for WebGL textures, not canvas drawing

Cursor to canvas coordinates

  • Raycast against an invisible helper plane the same size as the particle grid (visible = false, material side: THREE.DoubleSide or the effect dies when viewed from behind). Raycaster needs triangles; it cannot hit Points
  • The intersection conveniently includes .uv. Convert to canvas pixels: x = uv.x * canvas.width, y = (1 - uv.y) * canvas.height. The 1 - flip is mandatory: UV y goes up, canvas y goes down
  • Convert pointer events to clip space first (pointermove, x in -1..1, y negated), and initialize the cursor Vector2 at (9999, 9999) so nothing glows before the first move

Canvas to shader

displacement.texture = new THREE.CanvasTexture(displacement.canvas)
// every frame after drawing:
displacement.texture.needsUpdate = true
vec3 newPosition = position;   // attributes are read-only, copy first
float displacementIntensity = texture(uDisplacementTexture, uv).r;
displacementIntensity = smoothstep(0.1, 0.3, displacementIntensity);

vec3 displacement = vec3(cos(aAngle) * 0.2, sin(aAngle) * 0.2, 1.0);
displacement = normalize(displacement);
displacement *= displacementIntensity * 3.0 * aIntensity;
newPosition += displacement;
  • smoothstep(0.1, ...) floor: 2D canvas fades never reach true black (8-bit precision), so without a threshold particles never return home
  • smoothstep(..., 0.3) ceiling: values above 0.3 clamp to full displacement, which HOLDS particles up briefly as the trail decays, creating the lingering effect
  • Per-particle aIntensity (random 0..1) and aAngle (random 0..2 PI, driving cos/sin lateral drift) break up the uniform bulge

Particle morphing: two position attributes plus progress

Morph thousands of particles between shapes with zero CPU attribute updates: put both shapes in attributes and mix in the vertex shader.

Harmonizing vertex counts

Models never have equal vertex counts. Normalize every shape's position array to the largest count:

const positions = gltf.scene.children.map(child => child.geometry.attributes.position)
particles.maxCount = Math.max(...positions.map(p => p.count))

for(const position of positions) {
    const newArray = new Float32Array(particles.maxCount * 3)
    for(let i = 0; i < particles.maxCount; i++) {
        const i3 = i * 3
        if(i3 < position.array.length) {
            newArray.set(position.array.slice(i3, i3 + 3), i3)
        } else {
            // pad with COPIES of random existing vertices, never zeros
            const randomIndex = Math.floor(position.count * Math.random()) * 3
            newArray.set(position.array.slice(randomIndex, randomIndex + 3), i3)
        }
    }
    particles.positions.push(new THREE.Float32BufferAttribute(newArray, 3))
}
  • Padding with zeros creates an ugly clump at the origin; duplicating random vertices just makes some spots slightly brighter, which reads as intentional
  • Use a raw BufferGeometry with setAttribute('position', particles.positions[index]) and setAttribute('aPositionTarget', particles.positions[targetIndex]). GLTF geometries are indexed, but you extract only the position attribute, so no setIndex(null) needed here

The mix, staggered by noise

#include ../includes/simplexNoise3d.glsl

float noiseOrigin = simplexNoise3d(position * 0.2);
float noiseTarget = simplexNoise3d(aPositionTarget * 0.2);
float noise = mix(noiseOrigin, noiseTarget, uProgress);
noise = smoothstep(-1.0, 1.0, noise);   // simplex returns -1..1

float duration = 0.4;
float delay = (1.0 - duration) * noise;   // max delay keeps every particle finishing by 1
float end = delay + duration;
float progress = smoothstep(delay, end, uProgress);

vec3 mixedPosition = mix(position, aPositionTarget, progress);
vec4 modelPosition = modelMatrix * vec4(mixedPosition, 1.0);
  • Every particle shares one duration; noise only shifts its start. delay = (1.0 - duration) * noise guarantees all particles land exactly when uProgress hits 1
  • Noise sampled from position makes neighboring particles depart together, so chunks of the model peel off instead of dissolving uniformly. Mixing origin-noise and target-noise by uProgress makes the stagger correct in BOTH directions
  • Simplex over Perlin: less grid-like, cheaper, especially in higher dimensions
  • Color for free: vColor = mix(uColorA, uColorB, noise) tints the chunks that move first
  • Animate from JS: swap position to the current shape, aPositionTarget to the next, then gsap.fromTo(uProgress, { value: 0 }, { value: 1, duration: 3, ease: 'linear' }). Linear, because the smoothsteps in the shader already ease. Store the current index so the next morph starts from the right shape

Frustum culling gotcha

Three computes the bounding sphere from the position attribute (the shape you are LEAVING), so the mesh vanishes when the camera pans if the target shape is larger. Fix: particles.points.frustumCulled = false. Fine when the particles are the whole experience and always on screen.

GPGPU flow field particles

Flow field: for any point in space, a direction. Following it needs persistent per-particle state (a position that evolves every frame), which attributes cannot provide at scale. GPGPU stores particle state in a texture (FBO): one pixel per particle, RGB = XYZ, A free for extra data (lifetime here). Each frame a fragment shader reads the previous texture and writes the next; two render targets alternate because you cannot read and write the same FBO (ping-pong buffers).

GPUComputationRenderer setup

three/addons/misc/GPUComputationRenderer.js handles the offscreen scene, ping-pong, and pixel formats.

import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js'

const gpgpu = {}
gpgpu.size = Math.ceil(Math.sqrt(baseGeometry.count))   // square texture, one pixel per particle
gpgpu.computation = new GPUComputationRenderer(gpgpu.size, gpgpu.size, renderer)

// Seed texture: xyz from the geometry, alpha = random lifetime offset
const baseParticlesTexture = gpgpu.computation.createTexture()
for(let i = 0; i < baseGeometry.count; i++) {
    const i3 = i * 3, i4 = i * 4
    baseParticlesTexture.image.data[i4 + 0] = baseGeometry.instance.attributes.position.array[i3 + 0]
    baseParticlesTexture.image.data[i4 + 1] = baseGeometry.instance.attributes.position.array[i3 + 1]
    baseParticlesTexture.image.data[i4 + 2] = baseGeometry.instance.attributes.position.array[i3 + 2]
    baseParticlesTexture.image.data[i4 + 3] = Math.random()
}

gpgpu.particlesVariable = gpgpu.computation.addVariable('uParticles', gpgpuParticlesShader, baseParticlesTexture)
gpgpu.computation.setVariableDependencies(gpgpu.particlesVariable, [ gpgpu.particlesVariable ])  // feed itself back
gpgpu.particlesVariable.material.uniforms.uTime = new THREE.Uniform(0)
gpgpu.particlesVariable.material.uniforms.uDeltaTime = new THREE.Uniform(0)
gpgpu.particlesVariable.material.uniforms.uBase = new THREE.Uniform(baseParticlesTexture)
gpgpu.computation.init()

Per frame, before rendering:

gpgpu.particlesVariable.material.uniforms.uTime.value = elapsedTime
gpgpu.particlesVariable.material.uniforms.uDeltaTime.value = deltaTime
gpgpu.computation.compute()
particles.material.uniforms.uParticlesTexture.value =
    gpgpu.computation.getCurrentRenderTarget(gpgpu.particlesVariable).texture
  • Re-fetch getCurrentRenderTarget(...).texture EVERY frame; ping-pong means the current target alternates
  • Debug by mapping that texture onto a MeshBasicMaterial plane; it looks like colored noise where each pixel is one particle's coordinates
  • Random alpha seeds desynchronize lifetimes so the whole cloud does not respawn at once

The compute shader (gpgpu/particles.glsl)

A fragment shader over the data texture. resolution is injected automatically, uParticles is the previous frame's state.

#include ../includes/simplexNoise4d.glsl
uniform float uTime;
uniform float uDeltaTime;
uniform sampler2D uBase;
uniform float uFlowFieldInfluence;
uniform float uFlowFieldStrength;
uniform float uFlowFieldFrequency;

void main()
{
    float time = uTime * 0.2;
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);
    vec4 base = texture(uBase, uv);

    if(particle.a >= 1.0)   // dead: respawn at origin position
    {
        particle.a = mod(particle.a, 1.0);   // mod, not 0.0: survives huge deltaTime (tab switch)
        particle.xyz = base.xyz;
    }
    else                    // alive: follow the flow field
    {
        // How strongly this particle is affected, varies over space and time
        float strength = simplexNoise4d(vec4(base.xyz * 0.2, time + 1.0));
        float influence = (uFlowFieldInfluence - 0.5) * (- 2.0);   // 0..1 tweak to +1..-1 edge
        strength = smoothstep(influence, 1.0, strength);

        // 4D simplex noise per axis, offset inputs so axes decorrelate, time as 4th dimension
        vec3 flowField = vec3(
            simplexNoise4d(vec4(particle.xyz * uFlowFieldFrequency + 0.0, time)),
            simplexNoise4d(vec4(particle.xyz * uFlowFieldFrequency + 1.0, time)),
            simplexNoise4d(vec4(particle.xyz * uFlowFieldFrequency + 2.0, time))
        );
        flowField = normalize(flowField);
        particle.xyz += flowField * uDeltaTime * strength * uFlowFieldStrength;

        // Lifetime
        particle.a += uDeltaTime * 0.3;
    }

    gl_FragColor = particle;
}

Key decisions:

  • Offsetting the three noise inputs (+0, +1, +2) is what stops every particle drifting along one diagonal
  • The time component in the 4D noise keeps streams evolving; without it particles lock into closed loops
  • strength is sampled at base.xyz (the anchor), not the current position, so a region of the MODEL churns while the rest holds its shape. The smoothstep lower edge is the influence dial: higher edge, fewer particles affected
  • Everything motion-related multiplies uDeltaTime, or high-refresh monitors run the simulation faster and particles die early
  • mod(particle.a, 1.0) on death instead of resetting to 0.0: one giant deltaTime (background tab) would otherwise kill every particle simultaneously and synchronize the cloud forever
  • Lifetime data rides in the alpha channel because RGB is taken by position; rgba and xyzw are interchangeable swizzles

Rendering: particles read the texture by UV reference

The display geometry has NO position attribute. Each vertex instead carries the UV of its pixel in the data texture:

particles.geometry = new THREE.BufferGeometry()
particles.geometry.setDrawRange(0, baseGeometry.count)   // no position attr, so tell it how many points

const particlesUvArray = new Float32Array(baseGeometry.count * 2)
for(let y = 0; y < gpgpu.size; y++)
    for(let x = 0; x < gpgpu.size; x++) {
        const i2 = (y * gpgpu.size + x) * 2
        particlesUvArray[i2 + 0] = (x + 0.5) / gpgpu.size   // +0.5 targets the pixel CENTER
        particlesUvArray[i2 + 1] = (y + 0.5) / gpgpu.size
    }
particles.geometry.setAttribute('aParticlesUv', new THREE.BufferAttribute(particlesUvArray, 2))
particles.geometry.setAttribute('aColor', baseGeometry.instance.attributes.color)  // baked vertex colors
particles.geometry.setAttribute('aSize', new THREE.BufferAttribute(sizesArray, 1))
// particles/vertex.glsl
vec4 particle = texture(uParticlesTexture, aParticlesUv);
vec4 modelPosition = modelMatrix * vec4(particle.xyz, 1.0);

// Scale in at birth, out before death, using the lifetime in alpha
float sizeIn = smoothstep(0.0, 0.1, particle.a);
float sizeOut = 1.0 - smoothstep(0.7, 1.0, particle.a);
float size = min(sizeIn, sizeOut);
gl_PointSize = size * aSize * uSize * uResolution.y;
gl_PointSize *= (1.0 / - viewPosition.z);
  • The +0.5 half-pixel offset matters: sampling cell corners bleeds into neighboring particles' data
  • The size-in/size-out envelope hides the respawn teleport; without it dead particles visibly pop back to the model
  • Model loading with top-level await gltfLoader.loadAsync(...) keeps the GPGPU setup flat (no callback nesting); Vite needs build.target: 'esnext' for that to build

Gotchas checklist

  • Renamed position/color attributes clash with ShaderMaterial's prepended declarations
  • uSize uniform created before the renderer cannot read getPixelRatio()
  • cos/sin rotation not multiplied by radius collapses particles onto a cylinder
  • Randomness baked into position pre-rotation stretches into ribbons; keep it in an attribute applied post-rotation
  • Indexed plane geometry used as points draws stacked duplicate particles; setIndex(null)
  • Canvas trail: restore globalCompositeOperation and globalAlpha before each draw phase; they are sticky state
  • Canvas y is flipped relative to UV y (1 - uv.y)
  • Canvas fade never reaches zero; floor the displacement with smoothstep or particles never come home
  • Raycaster cannot intersect Points; use an invisible DoubleSide plane
  • CanvasTexture needs needsUpdate = true every frame it changes
  • Morph padding with zeros clumps particles at the origin; duplicate random vertices instead
  • Morph delay must satisfy delay + duration <= 1 or late particles never finish
  • Morphing mesh vanishing off-axis is stale bounding-sphere frustum culling; frustumCulled = false
  • GPGPU texture must be re-fetched from getCurrentRenderTarget each frame (ping-pong)
  • Same input to all three flow field noise calls moves everything diagonally; offset each axis
  • No time in the noise means particles orbit in fixed loops
  • Missing uDeltaTime scaling ties simulation speed to refresh rate
  • Resetting lifetime to 0.0 instead of mod(a, 1.0) synchronizes all particles after a long frame
  • UV reference attribute without the half-pixel offset samples wrong pixels
  • Geometry with no position attribute draws nothing unless setDrawRange sets the count

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-particles-and-gpgpu" })
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