GPU particles

GPU 파티클

Letting the GPU compute every particle’s position inside the vertex shader, instead of the CPU recalculating them each frame.

Also known as: Vertex shader particlesGPU-driven simulation
···
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05040a);
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camera.position.set(0, 0.6, 5.2);

const COUNT = 4000;
const seed = new Float32Array(COUNT * 4);
for (let i = 0; i < COUNT; i++) {
  seed[i * 4 + 0] = (Math.random() - 0.5) * 1.4;
  seed[i * 4 + 1] = Math.random() * 6.2831852;
  seed[i * 4 + 2] = 0.6 + Math.random() * 1.2;
  seed[i * 4 + 3] = Math.random();
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(COUNT * 3), 3));
geo.setAttribute('aSeed', new THREE.Float32BufferAttribute(seed, 4));

const vertexShader = [
  'attribute vec4 aSeed;',
  'uniform float uTime;',
  'varying float vLife;',
  'void main() {',
  '  float life = fract(aSeed.w + uTime * 0.12 * aSeed.z);',
  '  float angle = aSeed.y + life * 2.0;',
  '  float radius = aSeed.x + life * 0.6;',
  '  vec3 p = vec3(cos(angle) * radius, life * 3.2 - 1.4, sin(angle) * radius);',
  '  vLife = life;',
  '  vec4 mv = modelViewMatrix * vec4(p, 1.0);',
  '  gl_PointSize = (1.0 - life) * 22.0 * (1.0 / -mv.z);',
  '  gl_Position = projectionMatrix * mv;',
  '}',
].join('\n');

const fragmentShader = [
  'varying float vLife;',
  'void main() {',
  '  vec2 uv = gl_PointCoord - 0.5;',
  '  float d = length(uv);',
  '  if (d > 0.5) discard;',
  '  float alpha = smoothstep(0.5, 0.0, d) * (1.0 - vLife);',
  '  vec3 hot = vec3(1.0, 0.85, 0.4);',
  '  vec3 cool = vec3(1.0, 0.35, 0.15);',
  '  vec3 color = mix(cool, hot, 1.0 - vLife);',
  '  gl_FragColor = vec4(color, alpha);',
  '}',
].join('\n');

const mat = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader,
  fragmentShader,
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geo, mat);
points.frustumCulled = false;
scene.add(points);

function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
  mat.uniforms.uTime.value = t * 0.001;
  points.rotation.y = t * 0.00008;
  renderer.render(scene, camera);
});

A typical particle system updates every particle’s position in JS each frame and re-uploads a BufferAttribute to the GPU. Past a few thousand particles, that CPU loop and upload itself becomes the bottleneck. GPU particles flip the idea: upload each particle’s unchanging "seed" values (starting angle, radius, speed, a random phase) once as an attribute, and recompute the actual position every frame inside the vertex shader using nothing but that seed and a single uniform float uTime.

That means the only thing JS touches per frame is uTime, and the actual position math for thousands of particles all runs in parallel on the GPU. The demo gives each of 4,000 particles a vec4 attribute (aSeed) holding radius, angular speed, and a life offset, then builds a repeating 0→1 "life" value in the shader with fract(seed.w + uTime * speed) to spiral particles upward and fade them out like embers.

The difference from particle-field: that entry is a static point cloud with fixed positions, while here each point animates itself inside the shader. Shrinking gl_PointSize inversely with distance adds perspective too.

When to use

Animating particle counts in the thousands to tens of thousands — sparks, snow, magic effects — smoothly.