GPGPU 파티클 시뮬레이션

GPGPU particle simulation

파티클 위치를 CPU가 아니라 텍스처에 저장하고 GPU 셰이더로 갱신해서 수만 개를 매끄럽게 굴리는 기법.

다른 이름: Ping-pong render targetsRender-to-texture simulationGPUComputationRenderer
···
js
import * as THREE from 'three';
const SIZE = 48;
const COUNT = SIZE * SIZE;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

const initData = new Float32Array(COUNT * 4);
for (let i = 0; i < COUNT; i++) {
  const r = 0.4 + Math.random() * 1.3;
  const th = Math.random() * Math.PI * 2;
  const ph = Math.acos(2 * Math.random() - 1);
  initData[i * 4] = r * Math.sin(ph) * Math.cos(th);
  initData[i * 4 + 1] = r * Math.sin(ph) * Math.sin(th);
  initData[i * 4 + 2] = r * Math.cos(ph);
  initData[i * 4 + 3] = 1;
}
const initTex = new THREE.DataTexture(initData, SIZE, SIZE, THREE.RGBAFormat, THREE.FloatType);
initTex.minFilter = THREE.NearestFilter;
initTex.magFilter = THREE.NearestFilter;
initTex.needsUpdate = true;

function makeRT() {
  return new THREE.WebGLRenderTarget(SIZE, SIZE, {
    type: THREE.HalfFloatType,
    format: THREE.RGBAFormat,
    minFilter: THREE.NearestFilter,
    magFilter: THREE.NearestFilter,
    depthBuffer: false,
    stencilBuffer: false,
  });
}
let rtRead = makeRT();
let rtWrite = makeRT();

const quadGeo = new THREE.PlaneGeometry(2, 2);
const passCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
passCamera.position.z = 1;
passCamera.lookAt(0, 0, 0);

const passVertex = 'varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }';

const copyMat = new THREE.ShaderMaterial({
  uniforms: { tSrc: { value: initTex } },
  vertexShader: passVertex,
  fragmentShader: 'uniform sampler2D tSrc; varying vec2 vUv; void main() { gl_FragColor = texture2D(tSrc, vUv); }',
});
const copyScene = new THREE.Scene();
copyScene.add(new THREE.Mesh(quadGeo, copyMat));
renderer.setRenderTarget(rtRead);
renderer.render(copyScene, passCamera);
renderer.setRenderTarget(null);

const simFrag = [
  'uniform sampler2D tPrev;',
  'uniform float uTime;',
  'uniform float uDelta;',
  'varying vec2 vUv;',
  'float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }',
  'float vnoise(vec3 p) {',
  '  vec3 i = floor(p); vec3 f = fract(p); f = f * f * (3.0 - 2.0 * f);',
  '  float n000 = hash(i), n100 = hash(i + vec3(1.0,0.0,0.0)), n010 = hash(i + vec3(0.0,1.0,0.0)), n110 = hash(i + vec3(1.0,1.0,0.0));',
  '  float n001 = hash(i + vec3(0.0,0.0,1.0)), n101 = hash(i + vec3(1.0,0.0,1.0)), n011 = hash(i + vec3(0.0,1.0,1.0)), n111 = hash(i + vec3(1.0,1.0,1.0));',
  '  float nx00 = mix(n000, n100, f.x), nx10 = mix(n010, n110, f.x), nx01 = mix(n001, n101, f.x), nx11 = mix(n011, n111, f.x);',
  '  float nxy0 = mix(nx00, nx10, f.y), nxy1 = mix(nx01, nx11, f.y);',
  '  return mix(nxy0, nxy1, f.z);',
  '}',
  'vec3 curl(vec3 p) {',
  '  float e = 0.2;',
  '  float dx = vnoise(p + vec3(e,0.0,0.0)) - vnoise(p - vec3(e,0.0,0.0));',
  '  float dy = vnoise(p + vec3(0.0,e,0.0)) - vnoise(p - vec3(0.0,e,0.0));',
  '  float dz = vnoise(p + vec3(0.0,0.0,e)) - vnoise(p - vec3(0.0,0.0,e));',
  '  return vec3(dy - dz, dz - dx, dx - dy);',
  '}',
  'void main() {',
  '  vec3 pos = texture2D(tPrev, vUv).xyz;',
  '  vec3 vel = curl(pos * 0.55 + uTime * 0.06) * 1.6;',
  '  pos += vel * uDelta;',
  '  float d = length(pos);',
  '  if (d > 2.2) pos *= 0.985;',
  '  gl_FragColor = vec4(pos, 1.0);',
  '}',
].join('\n');
const simMat = new THREE.ShaderMaterial({
  uniforms: { tPrev: { value: rtRead.texture }, uTime: { value: 0 }, uDelta: { value: 0 } },
  vertexShader: passVertex,
  fragmentShader: simFrag,
});
const simScene = new THREE.Scene();
simScene.add(new THREE.Mesh(quadGeo, simMat));

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05060c);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 5.4);
camera.lookAt(0, 0, 0);

const ref = new Float32Array(COUNT * 2);
const dummyPos = new Float32Array(COUNT * 3);
let p = 0;
for (let j = 0; j < SIZE; j++) {
  for (let i = 0; i < SIZE; i++) {
    ref[p * 2] = (i + 0.5) / SIZE;
    ref[p * 2 + 1] = (j + 0.5) / SIZE;
    p++;
  }
}
const pointsGeo = new THREE.BufferGeometry();
pointsGeo.setAttribute('position', new THREE.BufferAttribute(dummyPos, 3));
pointsGeo.setAttribute('ref', new THREE.BufferAttribute(ref, 2));
const pointsMat = new THREE.ShaderMaterial({
  uniforms: { tPosition: { value: rtRead.texture } },
  vertexShader: [
    'uniform sampler2D tPosition;',
    'attribute vec2 ref;',
    'varying float vDist;',
    'void main() {',
    '  vec3 pos = texture2D(tPosition, ref).xyz;',
    '  vDist = length(pos);',
    '  vec4 mv = modelViewMatrix * vec4(pos, 1.0);',
    '  gl_PointSize = 32.0 / -mv.z;',
    '  gl_Position = projectionMatrix * mv;',
    '}',
  ].join('\n'),
  fragmentShader: [
    'varying float vDist;',
    'void main() {',
    '  vec2 c = gl_PointCoord - 0.5;',
    '  if (dot(c, c) > 0.25) discard;',
    '  vec3 colA = vec3(0.35, 0.55, 0.98);',
    '  vec3 colB = vec3(0.95, 0.4, 0.8);',
    '  gl_FragColor = vec4(mix(colA, colB, clamp(vDist / 2.2, 0.0, 1.0)), 0.92);',
    '}',
  ].join('\n'),
  transparent: true,
  depthWrite: false,
});
const points = new THREE.Points(pointsGeo, pointsMat);
points.frustumCulled = false;
scene.add(points);

function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
addEventListener('resize', resize); resize();

let last = 0;
renderer.setAnimationLoop((t) => {
  const time = t * 0.001;
  const delta = last === 0 ? 0.016 : Math.min(0.05, time - last);
  last = time;
  simMat.uniforms.tPrev.value = rtRead.texture;
  simMat.uniforms.uTime.value = time;
  simMat.uniforms.uDelta.value = delta;
  renderer.setRenderTarget(rtWrite);
  renderer.render(simScene, passCamera);
  renderer.setRenderTarget(null);
  const tmp = rtRead; rtRead = rtWrite; rtWrite = tmp;
  pointsMat.uniforms.tPosition.value = rtRead.texture;
  points.rotation.y = time * 0.1;
  renderer.render(scene, camera);
});

flow-field-particles처럼 JS 루프로 파티클을 하나씩 갱신하면 개수가 몇만 개만 넘어도 CPU가 병목이 됩니다. GPGPU(범용 GPU 연산) 방식은 위치 데이터 자체를 텍스처의 픽셀로 저장합니다 — 픽셀 하나가 파티클 하나, RGB가 xyz 좌표입니다. 그 텍스처를 고쳐 쓰는 연산을 프래그먼트 셰이더로 실행하면 수천 픽셀이 GPU에서 동시에 갱신됩니다.

이 사이트 번들엔 GPUComputationRenderer 애드온이 없어서, 같은 역할을 하는 핑퐁(ping-pong) 렌더 타깃 두 개를 직접 만듭니다: 매 프레임 "이전 위치" 텍스처(A)를 읽어 시뮬레이션 셰이더로 "다음 위치"를 텍스처(B)에 그리고, 다음 프레임엔 A·B를 서로 바꿔치기(swap)합니다. 시뮬레이션은 정점이 필요 없는 평면 하나를 OrthographicCamera로 정면에서 찍어 프래그먼트 셰이더만 돌리는 "풀스크린 패스"입니다.

실제 파티클을 그리는 THREE.Points의 정점 셰이더는 attribute로 받은 좌표(ref, 0~1)로 위치 텍스처를 texture2D로 읽어 gl_Position을 계산합니다 — position attribute는 더미이고 텍스처가 유일한 진실입니다. 렌더 타깃에 색 관리·톤매핑이 걸리면 좌표값이 뭉개지므로 ShaderMaterial로 픽셀을 직접 써야 하고, 정밀도가 필요하면 FloatType·HalfFloatType 텍스처를 씁니다.

언제 쓰나

수만~수십만 개의 파티클을 매끄럽게 움직여야 할 때, 또는 위치뿐 아니라 속도·수명 같은 상태까지 GPU에 얹어야 할 때.