GPU 파티클

GPU particles

파티클의 위치를 매 프레임 CPU에서 계산하지 않고, 정점 셰이더 안에서 GPU가 통째로 계산하게 하는 기법.

다른 이름: 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);
});

일반적인 파티클 시스템은 매 프레임 JS에서 각 입자의 위치를 갱신하고 BufferAttribute를 GPU로 다시 업로드합니다. 입자가 몇천 개를 넘어가면 이 CPU 루프와 업로드 자체가 병목이 됩니다. GPU 파티클은 발상을 뒤집습니다: 입자마다 변하지 않는 "씨앗값"(시작 각도, 반지름, 속도, 랜덤 위상 등)만 attribute로 한 번 올려두고, 실제 위치는 정점 셰이더 안에서 그 씨앗값과 uniform float uTime만으로 매 프레임 다시 계산합니다.

그러면 JS가 매 프레임 만지는 값은 uTime 하나뿐이고, 수천 개 입자의 실제 위치 계산은 전부 GPU가 병렬로 처리합니다. 데모는 4000개의 입자마다 반지름·각속도·수명 오프셋을 담은 vec4 attribute(aSeed)를 두고, 셰이더 안에서 fract(seed.w + uTime * speed)로 0→1 사이를 반복하는 "수명(life)"을 만들어 나선을 그리며 위로 솟았다가 사라지는 불티 같은 움직임을 냅니다.

같은 파티클이라도 particle-field 항목은 위치가 고정된 정적인 점 구름이고, 이 항목은 각 점이 셰이더 안에서 스스로 움직인다는 게 차이입니다. gl_PointSize를 거리에 반비례하게 줄이면 원근감도 살아납니다.

언제 쓰나

수천~수만 개 단위의 파티클(불꽃, 눈, 마법 이펙트)을 부드럽게 애니메이션할 때.