Points plus ShaderMaterial: the base recipe
PointsMaterialcannot be customized; swap it forShaderMaterialon aTHREE.Points. You losesizeandsizeAttenuationand must reimplement both- Standard vertex shader: the usual model/view/projection chain plus
gl_PointSize. Standard fragment tricks all readgl_PointCoord(per-particle UV, since varyings cannot describe a point's own surface) positionandcolorattributes are auto-declared byShaderMaterial; do NOT rename them toaPosition/aColoror they clash with the prepended declarations. Custom attributes get anaprefix (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_PointSizeis 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 auResolutionuniform:gl_PointSize = aSize * uSize * uResolution.y(uResolution includes pixel ratio, updated on resize)- The attenuation formula comes from Three's own
point_vertchunk:gl_PointSize *= (scale / - mvPosition.z)with scale simplified to 1.0.viewPositionis 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);
discardavoids alpha-sorting bugs entirely; its performance cost is usually negligible- The
0.05 / d - 0.1trick: 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.AdditiveBlendinganddepthWrite: falseinstead oftransparent: 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; multiplycos/sinby the radius or every star collapses onto a unit cylinder- Speed proportional to
1.0 / distanceToCentergives 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. Keeppositionon the clean spiral, ship the jitter as anaRandomnessvec3 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) andgeometry.deleteAttribute('normal') - In the vertex shader, sample a grayscale picture at the plane's
uvand 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
2dcontext, fill black. Each frame:- Fade:
globalCompositeOperation = 'source-over',globalAlpha = 0.02,fillRectthe whole canvas with black - Draw:
globalCompositeOperation = 'lighten'(additive-ish),globalAlpha = min(cursorSpeed * 0.1, 1),drawImagea grayscale glow image centered on the cursor
- Fade:
- Glow sizing:
glowSize = canvas.width * 0.25, and subtractglowSize * 0.5from both coordinates so the glow centers on the cursor instead of hanging off its bottom right - Cursor speed alpha: keep a
canvasCursorPreviousVector2,distanceTothe 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, materialside: THREE.DoubleSideor the effect dies when viewed from behind). Raycaster needs triangles; it cannot hitPoints - The intersection conveniently includes
.uv. Convert to canvas pixels:x = uv.x * canvas.width,y = (1 - uv.y) * canvas.height. The1 -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 homesmoothstep(..., 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) andaAngle(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
BufferGeometrywithsetAttribute('position', particles.positions[index])andsetAttribute('aPositionTarget', particles.positions[targetIndex]). GLTF geometries are indexed, but you extract only the position attribute, so nosetIndex(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) * noiseguarantees all particles land exactly whenuProgresshits 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
uProgressmakes 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
positionto the current shape,aPositionTargetto the next, thengsap.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(...).textureEVERY frame; ping-pong means the current target alternates - Debug by mapping that texture onto a
MeshBasicMaterialplane; 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
strengthis sampled atbase.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;
rgbaandxyzware 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.5half-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 needsbuild.target: 'esnext'for that to build
Gotchas checklist
- Renamed
position/colorattributes clash with ShaderMaterial's prepended declarations uSizeuniform created before the renderer cannot readgetPixelRatio()cos/sinrotation 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
globalCompositeOperationandglobalAlphabefore 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
CanvasTextureneedsneedsUpdate = trueevery 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
getCurrentRenderTargeteach 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
uDeltaTimescaling 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
setDrawRangesets the count