Particle에 대한 Attractor Force 적용

particle 시뮬레이션에서 attractor force(어트랙터 힘)란, 입자(particle)가 특정 지점 또는 객체를 향해 끌려가도록 만드는 힘을 의미한다. 이는 중력, 자기력, 전기력과 같은 물리적 개념을 추상화한 것으로, 시뮬레이션에서 입자의 집합적 거동을 제어하는 핵심 메커니즘 중 하나이다.

가장 기본적인 attractor force는 입자 위치와 어트랙터 위치 사이의 거리 벡터를 기반으로 계산된다. 일반적으로 힘의 방향은 (attractorPosition – particlePosition)으로 정의되며, 크기는 거리의 함수로 결정된다. 이때 거리 제곱에 반비례하는 형태(예: 중력 공식)를 사용하면, 가까울수록 강하게 끌리고 멀어질수록 약해지는 자연스러운 움직임을 만들 수 있다.

수식적으로는 다음과 같은 형태가 자주 사용된다.

F = G * (dir / (distance^n + ε))

여기서 dir은 정규화된 방향 벡터, distance는 입자와 어트랙터 사이의 거리, n은 감쇠 차수(보통 1 또는 2), ε는 수치적 불안정을 방지하기 위한 작은 값이다. 이 구조는 물리적으로 그럴듯하면서도 계산 비용이 비교적 낮다는 장점이 있다.

시뮬레이션 관점에서 attractor force는 속도(velocity)에 누적(accumulate)되는 힘으로 처리된다. 즉, 매 프레임마다 어트랙터 힘을 계산해 가속도(acceleration)에 반영하고, 이를 적분하여 속도와 위치를 갱신한다. 이 과정에서 타임스텝(delta time)을 고려하지 않으면 프레임 레이트에 따라 시뮬레이션 결과가 달라질 수 있다.

attractor는 단일 지점일 수도 있고, 여러 개가 동시에 존재할 수도 있다. 다중 어트랙터 환경에서는 각 어트랙터로부터의 힘을 합산하여 최종 힘을 계산하며, 이로 인해 입자가 특정 궤도에 머무르거나 카오스적인 움직임을 보이는 패턴이 생성된다. 이는 파티클 아트, 데이터 시각화, 은하 시뮬레이션 등에서 자주 활용된다.

실무적으로는 attractor force를 그대로 적용하면 입자가 과도하게 가속되어 불안정해지는 경우가 많기 때문에, force clamp(최대 힘 제한), damping(감쇠), 또는 soft radius(일정 거리 이내에서만 작동) 같은 보정 기법을 함께 사용한다. 이러한 제어 장치는 시각적으로 안정적이고 예측 가능한 결과를 만드는 데 필수적이다.

이제 구현 관점에서 설펴보자.

먼저 파티클을 간단히 정의해 보자.

class Particle {
  constructor(x, y, z) {
    this.position = new Vector3(x, y, z);
    this.velocity = new Vector3(0, 0, 0);
    this.mass = 1.0;
  }
}

각 파티클은 위치(position), 속도(velocity), 질량(mass)을 가진다. 질량은 힘 → 가속도 변환에 사용된다. 이제 Attractor에 대해 정의해 보자. 어트랙터는 위치와 힘의 세기(strength)를 가진다. 물리적으로는 “질량을 가진 중심점”에 해당한다.

class Attractor {
  constructor(x, y, z, strength = 10.0) {
    this.position = new Vector3(x, y, z);
    this.strength = strength;
  }
}

파티클에 대한 어트랙터가 미치는 힘의 영향을 계산하는 함수다. 입자 → 어트랙터 방향 벡터를 구하고, 거리 기반 감쇠를 적용한다. 여기서는 거리 제곱에 반비례하는 힘 모델을 사용한다.

function computeAttractorForce(particle, attractor) {
  const dir = attractor.position.clone().sub(particle.position);
  const distanceSq = Math.max(dir.lengthSq(), 0.0001); // 수치 안정성
  dir.normalize();

  // F = G / r^2
  const forceMagnitude = attractor.strength / distanceSq;

  return dir.multiplyScalar(forceMagnitude);
}

이제 시뮬레이션이 가능하다. 매 프레임마다 힘 → 가속도 → 속도 → 위치 순서로 갱신한다. 이 예제에서는 감쇠(damping)를 추가해 발산을 방지한다.

function updateParticle(particle, attractor, deltaTime) {
  // 1. attractor force 계산
  const force = computeAttractorForce(particle, attractor);

  // 2. F = m * a → a = F / m
  const acceleration = force.divideScalar(particle.mass);

  // 3. 속도 갱신
  particle.velocity.add(acceleration.multiplyScalar(deltaTime));

  // 4. 감쇠 (damping)
  particle.velocity.multiplyScalar(0.98);

  // 5. 위치 갱신
  particle.position.add(particle.velocity.clone().multiplyScalar(deltaTime));
}

three.js라면 Vector3 클래스를 제공하지만 three.js가 아닌 환경이라면 Vector3에 대한 정의는 다음과 같다.

class Vector3 {
  constructor(x = 0, y = 0, z = 0) {
    this.x = x;
    this.y = y;
    this.z = z;
  }

  clone() {
    return new Vector3(this.x, this.y, this.z);
  }

  add(v) {
    this.x += v.x;
    this.y += v.y;
    this.z += v.z;
    return this;
  }

  sub(v) {
    this.x -= v.x;
    this.y -= v.y;
    this.z -= v.z;
    return this;
  }

  multiplyScalar(s) {
    this.x *= s;
    this.y *= s;
    this.z *= s;
    return this;
  }

  divideScalar(s) {
    return this.multiplyScalar(1 / s);
  }

  lengthSq() {
    return this.x * this.x + this.y * this.y + this.z * this.z;
  }

  normalize() {
    const len = Math.sqrt(this.lengthSq());
    if (len > 0) this.divideScalar(len);
    return this;
  }
}

핵심을 좀더 반복해 보면, attractor force는 위치 차 벡터 기반으로 계산한다는 점. 거리 기반 감쇠가 없으면 시뮬레이션이 쉽게 불안정해진다는 점. damping, 최소 거리 제한은 사실상 필수라는 점. 이 구조는 CPU 파티클, GPU 파티클(Compute / GPGPU) 모두에 동일하게 적용된다는 점 등이다.

WebGL 방식의 고전 GPGPU (three.js)

이제는 WebGPU로 인해 고전이 되어버린 three.js에서의 WebGL에서의 GPGPU 프로그래밍에 대한 코드를 정리한다. 고전이긴 하지만 아직 웹 3D는 WebGL이 대세이니… 팔팔한 노땅의 저력인 FBO(Frame Buffer Object) 기반으로 GPGPU 코드를 정리해 보면서 추후 다시 프로젝트에 적용할(일이 있을래나 싶기도하지만) 일을 대비해 정리해 둔다.

FBO 기반의 GPGPU 프로그래밍은 그 내부 흐름이 어색하고 다소 복잡하다. WebGL은 그래픽 API로 설계된지라 GPGPU와 같은 오직 계산(Computing)을 고려하지 않았기 때문이다. 그 복잡함에 대한 설명은 생략하고…. three.js는 그 복잡한 FBO 기반의 GPGPU를 좀더 쉽게 활용할 수 있도록 GPUComputationRenderer 클래스를 AddOn으로 제공한다. (참고로 WebGPU는 GPGPU에 대한 코드가 매우 자연스럽다)

import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js'

GPUComputationRenderer 객체를 생성해 원하는 데이터의 읽고 쓰기를 모두 GPU를 통해 수행할 수 있다. 이 객체의 생성 코드 예시는 다음과 같다.

const baseGeometry = {}
baseGeometry.instance = new THREE.SphereGeometry(3)
baseGeometry.count = baseGeometry.instance.attributes.position.count

const gpgpu = {}
gpgpu.size = Math.ceil(Math.sqrt(baseGeometry.count))
gpgpu.computation = new GPUComputationRenderer(gpgpu.size, gpgpu.size, renderer)

WebGL에서의 GPGPU는 텍스쳐(정확히는 THREE.DataTexture)를 이용한다. 그러기에 텍스쳐의 크기를 정해야 하는데, 위의 코드는 baseGeometry.instance에 저장된 지오메트리의 정점들에 대한 좌표값들을 텍스쳐의 픽셀에 인코딩(맵핑) 시키고 있다. GPGPU의 텍스쳐를 구성하는 각 픽셀은 R, G, B, A로 각각은 Float 타입이다. 즉, GPGPU의 텍스쳐를 구성하는 픽셀 하나 당 총 4개의 데이터 타입을 입력할 수 있다. GLSL에서 R, G, B, A는 각각 X, Y, Z, W와 그 이름이 같다. 위의 코드는 아직 FBO에 해당하는 데이터 텍스쳐를 생성하기 전이다. 데이터 텍스쳐를 생성하는 코드는 다음과 같다.

const baseParticlesTexture = gpgpu.computation.createTexture()

이렇게 만든 데이터 텍스쳐의 구성 픽셀 하나 하나에 지오메트리의 구성 정점의 좌표를 저장하는 코드는 다음과 같다.

for (let i = 0; i < baseGeometry.count; i++) {
    const i3 = i * 3
    const 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] = 0
}

위의 코드는 데이터 텍스쳐의 값에 대한 초기화 코드에 해당한다. GPU 연산에 비해 느려터진 CPU 코드를 통해 이런 초기화 코드는 한번쯤은 필요하다.

데이터 텍스쳐, 즉 FBO의 값을 읽고 쓰는 연산을 GPU에서 처리하기 위해서는 쉐이더 코드가 필요하다. 정확히는 Fragment 쉐이더이고 텍스쳐를 해당 쉐이더로 전달하기 위해 uniforms 방식이 사용된다. uniforms으로 전달될 객체 이름을 정의하는 코드는 다음과 같다.

gpgpu.particlesVariable = gpgpu.computation.addVariable('uParticles', gpgpuParticlesShader, baseParticlesTexture)
gpgpu.computation.setVariableDependencies(gpgpu.particlesVariable, [gpgpu.particlesVariable])

즉 uParticles라는 이름의 uniforms 이름으로 baseParticlesTexture 텍스쳐를 gpgpuParticlesShader라는 쉐이더로 전달한다는 것이다.

gpgpuParticlesShader는 문자열이다. 즉 쉐이더 코드이고 아래의 예시와 같다.

void main()
{
  vec2 uv = gl_FragCoord.xy/resolution.xy;

  vec4 particle = texture(uParticles, uv);
  particle.y += 0.01;
  
  gl_FragColor = particle;
}

uv를 통해 텍스쳐의 각 픽셀에 접근하기 위한 좌표를 정의하고, 해당 uv에 대한 데이터 텍스쳐(uParticles Uniforms로 지정)에서 픽셀값을 읽는다. 이렇게 읽은 픽셀값은 vec4이며 각각 r,g,b,a 또는 x,y,z,w로 읽을 수 있다. 해당 값은 변경할 수 있는데.... 변경했다면 gl_FragColor를 통해 변경된 값을 저장하면 실제로 변경된 값이 데이터 텍스쳐의 해당 위치(uv)에 맞게 저장된다.

이게 three.js에서 사용되는 WebGL 방식의 GPGPU 핵심 코드이다.

아! 지금까지 코드를 작성했고 실제 셋업의 완성을 위한 코드는 다음과 같다.

gpgpu.computation.init()

그럼... 이렇게 만든 FBO를 다른 재질의 쉐이더에서 사용하는 코드를 살펴볼 차례이다. 이를 위해서는 먼저 FBO의 각 픽셀을 참조하기 위한 인덱스가 필요하다.

const particles = {}

const particlesUvArray = new Float32Array(baseGeometry.count * 2)

for (let y = 0; y < gpgpu.size; y++) {
    for (let x = 0; x < gpgpu.size; x++) {
        const i = (y * gpgpu.size + x)
        const i2 = i * 2
        const uvX = (x + .5) / gpgpu.size
        const uvY = (y + .5) / gpgpu.size
        particlesUvArray[i2 + 0] = uvX;
        particlesUvArray[i2 + 1] = uvY;
    }
}

particles.geometry.setAttribute('aParticlesUv', new THREE.BufferAttribute(particlesUvArray, 2))

재질에 대한 FBO의 관계 설정은 다음과 같다.

particles.material = new THREE.ShaderMaterial({
    vertexShader: particlesVertexShader,
    fragmentShader: particlesFragmentShader,
    uniforms:
    {
        uSize: new THREE.Uniform(0.07),
        uResolution: new THREE.Uniform(new THREE.Vector2(sizes.width * sizes.pixelRatio, sizes.height * sizes.pixelRatio)),
        uParticlesTexture: new THREE.Uniform()
    },
    transparent: true,
    depthWrite: false,
})

위의 코드를 보면 uParticlesTexture이라는 Uniform이 보이는데, 이 Uniform 객체에 FBO를 연결한다. 해당 코드는 다음과 같다.

const tick = () => {
    gpgpu.computation.compute()
    particles.material.uniforms.uParticlesTexture.value = gpgpu.computation.getCurrentRenderTarget(gpgpu.particlesVariable).texture
}

tick는 프래임 렌더링마다 호출되는 함수인데, 먼저 GPGPU 연산을 통해 FBO를 업데이트하고 FBO에 연결된 재질의 uniform 객체에 FBO의 텍스쳐를 설정하면 된다.

참고로 FBO를 사용하는, 즉 연결된 재질에 대한 Vertex 쉐이더 코드의 예시는 다음과 같다. 이 코드를 통해 FBO가 어떻게 사용되는지를 엿볼 수 있다.

...

uniform sampler2D uParticlesTexture;
attribute vec2 aParticlesUv;

void main()
{
    vec4 particle = texture(uParticlesTexture, aParticlesUv);

    vec4 modelPosition = modelMatrix * vec4(particle.xyz, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectedPosition = projectionMatrix * viewPosition;
    gl_Position = projectedPosition;

    ...
}

끝으로 FBO를 업데이트 해주는 쉐이더에도 unforms 개념으로 데이터를 전달할 수 있다. uniforms 개념 형태로 전달하려면 재질을 참조해야 하는데 FBO의 해당 재질은 다음처럼 접근할 수 있다.

gpgpu.particlesVariable.material.uniforms.uTime = new THREE.Uniform(0)

이제 정말 끝.

[Python] pip 명령어

PIP는 Pythond의 Package 관리자이다. 개발언어인 파이선에서 패키지는 라이브러리 개념과 동일하다. PIP를 통한 패키지를 관리하기 위한 명령을 정리한다.

# pip 업데이트 (Linux)

pip install pip --upgrade

# pip 업데이트 (Windows)

python -m pip install --upgrade pip

# Python 설치(opencv-contrib-python 패키지)

pip install opencv-contrib-python

# Update 해야할 패키지 목록

pip list -o

# numpy 패키지 설치

pip install numpy

# numpy 패키지 업데이트

pip install numpy --upgrade

# 특정 패키지 상세 정보 보기

pip show 패키지_이름

# numpy 패키지 제거

pip uninstall numpy

# 설치된 전체 패키지 삭제

pip freeze > requirements.txt 
pip uninstall -r requirements.txt -y

# 특정 버전의 (또는 contrib 버전) opencv 패키지 설치(참조 url: https://pypi.org/project/opencv-contrib-python/3.4.5.20/)

pip install opencv-contrib-python==3.4.5.20