GPGPU particle simulation

GPGPU 파티클 시뮬레이션

Storing particle positions in a texture and updating them entirely on the GPU, so tens of thousands can move smoothly.

Also known as: 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);
});

Updating particles one by one in a JS loop, like flow-field-particles does, hits a CPU bottleneck once counts climb past a few tens of thousands. GPGPU (general-purpose GPU computation) stores the position data itself as pixels in a texture — one pixel per particle, RGB holding xyz. Running the update as a fragment shader lets thousands of pixels update simultaneously on the GPU.

This site’s vendor bundle has no GPUComputationRenderer, so this demo builds the same idea by hand with two ping-pong render targets: each frame reads the "previous position" texture (A), runs a simulation shader, and writes "next position" into texture (B); next frame A and B swap. The simulation itself is a "full-screen pass" — a single vertex-less plane shot head-on by an OrthographicCamera, where only the fragment shader does any work.

The THREE.Points that draws the particles has a vertex shader that reads its per-vertex ref attribute (0–1 coordinates) as a texture2D lookup into that position texture to compute gl_Position — the built-in position attribute is a dummy; the texture is the only source of truth. Since color management and tone mapping would corrupt raw coordinate values, the passes use ShaderMaterial to write pixels directly, and FloatType or HalfFloatType textures when precision matters.

When to use

Moving tens or hundreds of thousands of particles smoothly, or when state beyond position — velocity, lifetime — needs to live on the GPU too.