10

Part III · Compute Core Chapter 10 of 18 Build 2025.31550

입자 시뮬레이션

“입자 시뮬레이션은 attribute 갱신의 가장 자연스러운 형태이며, 따라서 GPU의 가장 자연스러운 작업이다.”

이 챕터에서 정복할 CG 개념

왜 이게 중요한가

POP은 GPU 그래픽스 파이프라인의 데이터 모델을 노드 그래프로 노출시킨 학습 환경이다. 그 데이터 모델에서 가장 자연스러운 작업이 입자 시뮬레이션이다. 입자는 본질적으로 attribute의 묶음이며, 각 입자의 갱신은 자기 attribute만 알면 된다 (이웃과의 상호작용을 무시한다면). 즉 thread 간 의존성이 없는 embarrassingly parallel 작업이다. GPU가 정확히 이런 작업을 위해 만들어졌다. GPU Gems 3 Ch.23 (off-screen particles), Ch.31 (N-body), Unity의 VFX Graph, Unreal의 Niagara, Houdini의 dopparticles — 모두 같은 추상화의 변주다. RTR4 §13 "Beyond Polygons"가 책 차원의 framing.

여기서는 한 프레임의 갱신식만 다룬다. 갱신을 누적해 시간을 흘리려면 이전 프레임의 출력을 다음 프레임의 입력으로 되먹임 해야 하고, 그 메커니즘이 Ch.11의 Feedback POP / ping-pong이다. 본 챕터는 입자 갱신의 "한 step"을 구체적으로 손에 익히는 것이 목표다.

POP에서의 노출 지점

Particle POP (노드 버전)

POP inventory에서 verbatim: "Particle POP — Creates and controls 'particles' (points with special attributes) for particle-system simulations, designed for feedback loops."

설계 의도가 분명하다: feedback loop과 짝지어 쓰도록 만들어진 POP이다. 즉 Particle POP 단독으로는 시간이 흐르지 않고, Feedback POP을 통해 자신의 이전 출력을 다시 받아야 갱신이 누적된다.

Force Radial POP

POP inventory verbatim: "Force Radial POP — Used inside a Particle POP loop; outputs a float3 PartForce attribute summing a set of radial forces."

Force Radial POP은 attractor/repulsor를 PartForce라는 attribute로 누적한다. Particle POP은 이 PartForce를 입자의 가속도 입력으로 본다.

Feedback POP

POP inventory verbatim: "Feedback POP — Receives the previous frame's output of a target POP and feeds it back, enabling iterative per-frame modification."

Feedback POP의 Target 파라미터에 누적할 POP을 지정하면, 이전 frame의 그 POP의 출력을 본 frame의 입력으로 다시 받는다. 정확한 setup은 Ch.11에서 자세히 — 본 챕터의 노드 버전 setup은 Particle POP + Force Radial POP + Feedback POP의 3개 노드 패턴만 명시한다.

여기서 중요한 것: TouchDesigner POP의 "POP" (Point Operator)과 Houdini의 옛 POP (Particle Operators)은 이름만 같고 무관하다. Houdini의 현행 입자 시스템은 dopparticles이다. 검색할 때 주의.

GLSL POP — 두 attribute(P, vel)에 동시에 쓰기

Initialize Output Attributes ON이면 첫 프레임에 vel은 (0,0,0)으로 초기화된다.

이론

입자가 왜 GPU에 자연스러운가

CPU 입자 시뮬레이션의 단일 thread 코드:

for i in range(N):
    particles[i].vel += a * dt
    particles[i].pos += particles[i].vel * dt

이 루프의 본질은 N개 element에 대한 독립 갱신이다. 각 i가 다른 i에 의존하지 않는다. CPU는 하나씩 순차 처리하면서 캐시 효율과 branch prediction에 의존한다. GPU는 N개를 동시에 처리한다.

같은 코드의 GPU compute shader:

uint i = TDIndex();
if (i >= N) return;
vel[i] = TDIn_vel(0, i) + a * dt;
P[i]   = TDIn_P(0, i) + vel[i] * dt;

루프가 사라지고 인덱스만 남는다. GPU 드라이버가 N개의 thread를 자동으로 dispatch하고 각 thread가 자기 i에 해당하는 element를 처리한다. CPU는 한 core당 시간당 ~10⁹ 명령. NVIDIA RTX 3070은 시간당 ~10¹³ 명령 (TFLOPS 기준). 입자 백만 개의 갱신이 CPU에서 16 ms 걸리면 GPU에서는 0.016 ms — 1000배 차이.

이 비교가 단순화한 부분도 있다. CPU는 SIMD instruction (AVX2 등)으로 4–8x 가속할 수 있고, multi-threading으로 또 8–16x. 그래도 한 자릿수 차이는 좁히지 못한다. GPU 입자 시뮬은 실시간 수십만~수백만 단위에서만 의미가 있다.

입자 상태와 갱신식

입자 하나의 상태는 적어도 두 attribute:

P   : vec3   현재 위치
vel : vec3   현재 속도

종종 추가:

age      : float   생성 후 경과 시간
lifespan : float   사라질 때까지의 시간
mass     : float   외력에 대한 응답 계수
Cd       : vec3   color (시각화)

매 프레임 갱신식 (Euler 명시적):

a_t = (외력의 합) / mass
v_{t+dt} = v_t + a_t * dt
p_{t+dt} = p_t + v_{t+dt} * dt

p += v_{t+dt} * dt (semi-implicit Euler)는 p += v_t * dt (explicit Euler)보다 안정적이다. 동일한 ALU 비용으로 진동 발산을 줄인다.

적분 방법의 분류 — 무엇을 고를 것인가

수치 적분은 ODE를 이산화하는 방법이다. 입자 운동의 ODE는 dv/dt = a(p, v, t), dp/dt = v. 이산화 방법:

방법 정확도 안정성 비용
Explicit Euler v_{t+1} = v_t + a*dt, p_{t+1} = p_t + v_t*dt 1차 낮음 1x
Semi-implicit Euler v_{t+1} = v_t + a*dt, p_{t+1} = p_t + v_{t+1}*dt 1차 중간 (조화진동에 unconditionally stable) 1x
Velocity Verlet 위치/속도/가속을 짝지어 갱신 2차 좋음 (에너지 보존) 1.5x
RK4 (Runge-Kutta) 한 step 안에서 force를 4번 평가 4차 매우 좋음 4x

실시간 입자 시각화에는 semi-implicit Euler가 표준이다. 정확도 < 시각적 자연스러움 < 안정성의 우선순위로, semi-implicit이 그 균형을 가장 잘 맞춘다.

RK4는 천체역학 / 정밀 fluid에 쓰이지만 force 평가가 4배라 GPU 입자 백만 단위에서는 부담. Verlet은 에너지 보존이 필요한 cloth / soft body에 적합.

본 챕터의 GLSL 셰이더는 semi-implicit Euler. 한 step 안에서 vel을 먼저 갱신하고 갱신된 vel로 p를 갱신한다 — 코드 두 줄 차이로 explicit보다 훨씬 안정적.

외력 종류

외력 특성
중력 g = (0, -9.81, 0) 일정한 벡터
Drag (점성 마찰) f = -k * v 속도에 비례, 항상 감쇠
Radial attractor `f = K * (target - p) / target - p
Radial repulsor 부호만 반대
Curl noise field f = curl(p, t) divergence-free → 점 밀도 보존
마찰 (multiplicative) v *= (1 - drag_coef) drag의 시간 적분 근사

multiplicative drag(v *= 0.99)는 식 정확도는 떨어지지만 GPU에서 한 곱셈으로 끝나므로 흔히 쓰인다. 강한 drag가 필요할 때만 명시적 f = -k*v 형태로 바꾼다.

외력의 누적 — 한 셰이더 안에서 합산

입자 시뮬레이션에서 외력은 보통 여러 종류가 동시에 작동한다. 한 셰이더 안에서 다음 같이 합산한다:

vec3 acc = vec3(0.0);

// 1. 중력
acc += vec3(0.0, -9.81, 0.0);

// 2. radial attractor
vec3 toCenter = -p;
float r = length(toCenter);
acc += normalize(toCenter) * K_attr / (r + 1.0);  // 분모 +1로 singular 방지

// 3. drag (속도에 비례한 감속)
acc += -v * dragCoef;

// 4. curl noise force
acc += curlNoise3(p * curlFreq + vec3(0, uTime * 0.1, 0)) * curlStrength;

// 적분
v += acc * dt;
p += v * dt;

각 외력이 독립적 항으로 들어가 acc에 누적된다. 노드 그래프 버전에서는 각 외력이 별도 Force POP 인스턴스(Force Radial, 다른 Force 노드)이며 Particle POP이 합산을 자동으로 한다.

GLSL 버전의 장점은 외력의 자유로운 형태이다. radial이 아닌 임의의 vector field (mesh normal 방향, SDF gradient, neural network 출력)도 한 줄로 추가 가능.

dt를 어떻게 정할 것인가

가장 단순한 선택: dt = 1/60로 고정. 60 Hz일 때 정확. 실제 frame time이 변하면 누적 시간이 어긋난다. 정확한 패턴은 absTime.frame의 변화량으로 dt를 계산해 uniform으로 전달하는 것 — Ch.11 ping-pong과 짝지어 다룬다. 본 챕터는 dt = 1/60 고정.

수치 적분의 안정성과 dt 선택

명시적 Euler는 unconditionally unstable한 system이 있다. 강한 spring force(f = -kx)에서 k * dt^2 > 4이면 진폭이 발산한다. 입자 시뮬에서 보통의 trap:

해법 우선순위:

  1. semi-implicit Euler (v_{t+dt}를 먼저 계산해 p에 쓰기) — 같은 ALU 비용으로 한 단계 안정성 ↑.
  2. substep: 한 frame 안에서 여러 번 적분. dt를 N으로 나누고 N번 dispatch. 정확도는 좋아지나 비용은 N배.
  3. velocity clamping: v = clamp(v, -v_max, v_max). 안정성 보장 대신 운동량이 부정확해짐.

본 챕터 코드는 (1)을 채택, (2)와 (3)은 시나리오에 따라 추가.

Stateful 시뮬레이션과 stateless 함수 — 어디에 state가 있는가

본 챕터의 GLSL 셰이더 한 번의 호출은 stateless 함수다. 입력 (P_in, vel_in)을 받아 출력 (P_out, vel_out)을 반환. 함수 자체는 어떤 state도 보존하지 않는다.

시뮬레이션이 stateful이 되는 것은 P_out이 다음 frame의 P_in으로 들어올 때다. 함수는 매 frame 새로 호출되지만, 함수 외부의 buffer가 state를 보존한다. 그 외부 buffer가 Feedback POP의 본질이다.

이 분리가 GPU 시뮬레이션 설계의 핵심이다:

CPU 시뮬에서 particles[i].vel += ...는 in-place mutation으로 buffer와 state가 한몸이다. GPU에서는 두 buffer가 분리되어 있고 ping-pong으로 swap된다. 같은 본질의 시뮬이지만 표현이 다르다.

Feedback이 왜 필요한가

본 챕터 셰이더 한 번의 dispatch만 보면:

vec3 v = TDIn_vel();
v += (force) * dt;
vel[id] = v;

여기서 TDIn_vel()은 GLSL POP의 입력 vel을 읽는다. 입력 vel은 어디서 오는가?

시간을 흐르게 하려면 본 노드의 출력을 다음 프레임의 입력으로 되먹임 해야 한다 — Feedback POP. 이 setup은 Ch.11에서 다룬다. 본 챕터의 GLSL 셰이더는 그 누적 과정의 한 step만 정확히 표현한다.

다이어그램 — 입자 1-step의 데이터 흐름

입력 attributes        ┌─ Compute Shader dispatch ──┐    출력 attributes
─────────────         │                            │   ─────────────
 P_in[id]   ─── read →│                            │→ write ─── P_out[id]
 vel_in[id] ─── read →│   v = vel_in + force*dt    │→ write ─── vel_out[id]
                      │   p = P_in + v*dt          │
 (uniforms)           │                            │
 uTime, uDt, ...   ───┤                            │
                      └────────────────────────────┘

dispatch 한 번에 두 buffer(P, vel)에 동시에 쓴다. 같은 attribute class(point)이고 같은 element 개수이므로 한 attr_class = point GLSL POP으로 가능.

세 class에 동시에 쓰는 시뮬레이션은 GLSL Advanced POP이 필요하다 (Ch.12).

입자가 죽고 새로 태어나는 패턴

본 챕터는 입자 개수가 변하지 않는 시뮬레이션만 다룬다. lifespan과 spawn을 고려하면 두 갈래 패턴이 있다.

  1. 고정 개수 풀(pool) + age 리셋: 입자 개수는 frame마다 같다. 각 입자에 age attribute를 둔다. age가 lifespan을 넘으면 age를 0으로 리셋하고 P를 spawn 위치로 되돌린다.
age += dt;
if (age > lifespan) {
    age = 0.0;
    p = spawnPos(id);  // id 기반 결정적 spawn
    v = vec3(0.0);
}

본 패턴은 GLSL POP의 single attribute class + bounds-fixed dispatch와 자연스럽게 들어맞는다. 입자 buffer 크기가 변하지 않으므로 GPU memory allocation도 한 번만 일어난다.

  1. 가변 개수 + compaction: alive 입자만 다음 frame에 갱신한다. dead 입자를 buffer 끝으로 모으거나(compaction), atomic counter로 alive 개수만큼만 indirect dispatch. 이 패턴은 GLSL Advanced POP(Ch.12)과 indirect dispatch를 같이 써야 깔끔하다. 본 챕터의 범위 밖.

대다수 실시간 시각화 작업에 (1) pool 패턴이 충분하다.

N-body 시뮬레이션은 왜 다른가

본 챕터의 외력은 모두 per-particle이다. 각 입자가 자기 P와 자기 vel만 알면 force가 결정된다. 그래서 thread 간 의존성이 없다.

N-body(중력으로 입자끼리 끌어당기는 시뮬)는 다르다. 입자 i의 force는 i 외의 모든 입자의 P를 알아야 한다 — O(N²) 의존성. 이런 시뮬을 단순한 GLSL POP으로 짜면:

for (uint j = 0; j < TDNumElements(); j++) {
    if (j == id) continue;
    vec3 r = TDIn_P(0, j) - p;
    force += G * mass / dot(r, r) * normalize(r);
}

TDIn_P(0, j) 호출이 N번 발생하므로 한 입자당 N번의 SSBO read. 총 N² read. point가 10,000개면 100,000,000 read — frame 한 번에 약 100 MB의 read traffic. 중급 GPU에서도 무겁다.

GPU Gems 3 Ch.31의 N-body는 shared memory tiling으로 이 비용을 줄인다. 한 workgroup이 P 배치 하나를 shared memory에 캐시하고, 모든 thread가 그 캐시에서 force 기여를 계산. SSBO read이 N에서 N/tileSize로 줄어든다.

이 패턴은 manual workgroup size + shared memory가 필요해 GLSL POP의 auto 모드로는 표현하기 까다롭다. 본격적인 N-body는 GLSL Advanced POP 또는 manual dispatch GLSL POP에서 다룬다 (Ch.13 neighborhood에서 비슷한 패턴 등장).

Mass와 charge — 입자별 응답을 다르게

본 챕터의 입자는 모두 같은 mass(=1)를 가정한다. mass attribute를 추가하면 입자마다 외력에 다르게 반응한다.

float m = TDIn_mass();
vec3 acc = (g + atr) / m;

마찬가지로 charge attribute로 전기력 / repulsion을 모델링할 수 있다. 두 attribute 모두 GLSL POP에서 한 번에 다룰 수 있다 — point class에 있으므로.

mass의 분산은 시각적으로 자연스러운 다양성을 만든다. 같은 외력에서 다양한 운동, 동기화되지 않은 입자 — 폭발 잔해, 먼지 입자 분포에 적합.

Vector field로서의 외력

본 챕터의 모든 외력은 결국 force(p, v, t)라는 함수다. 이 함수가 시간에 무관(force(p, v))하고 v에 무관(force(p))하면 static vector field가 된다. vector field는 GPU에서 두 가지 형태로 표현할 수 있다.

  1. 함수 형태: 셰이더 안에서 매번 evaluate. 예: vec3 f(vec3 p) { return -normalize(p) * K; }. 메모리는 0, ALU는 시간에 따라.
  2. 텍스처 형태: 3D 텍스처에 한 frame에 한 번 미리 쌓아 둠 (Noise TOP / TOP feedback). GLSL POP에서 sampler3D로 sample.

복잡한 형태(예: SDF 기반 attractor, mesh 표면에서의 normal force)는 텍스처 형태가 자연스럽다. 단순한 형태(중력, drag, radial)는 함수 형태가 빠르다.

입자 운동의 정성적 패턴

같은 적분식 + 같은 입자 초기 조건이라도 외력에 따라 운동의 정성이 크게 달라진다.

외력 조합 정성적 운동 시각적 인상
중력만 자유낙하, 포물선 폭포
중력 + 강한 drag 종단속도, 부드러운 낙하 눈, 깃털
중력 + 약한 attractor 진자 운동, 진동 펜듈럼 클라우드
Curl noise만 무중력 fluid flow, 무한 순환 연기, 안개
Curl noise + 약한 attractor 영역에 머무는 fluid 토네이도, 소용돌이
강한 attractor + drag 빠른 수렴, "빨려 들어감" 블랙홀
repulsor + drag 폭발 후 정지 폭발 잔해
시간에 따라 변하는 노이즈 force 흐름의 변화 바람, 해류

예술적 의도에 맞는 정성을 정확히 만들려면 외력의 정성을 알아야 한다. 본 챕터의 hands-on은 (gravity + linear attractor + drag) 한 조합과 (curl noise + weak attractor) 두 조합을 다룬다.

Color / age / size attribute의 활용

본 챕터 GLSL 셰이더는 P와 vel만 갱신한다. 시각화에 흔히 추가하는 attribute:

이들 모두 같은 GLSL POP의 같은 dispatch에서 한 번에 갱신 가능하다 (point class에 있는 한). Output AttributesP vel Cd pscale age를 모두 등록하고, Create Attributes에서 새 attribute의 type을 정의.

// speed에 따른 color
float speed = length(v);
Cd[id] = mix(vec3(0.2, 0.4, 1.0), vec3(1.0, 0.3, 0.1), clamp(speed * 0.5, 0.0, 1.0));

// age 누적
age[id] = TDIn_age() + dt;

각 attribute의 갱신은 다른 attribute에 의존하지 않는다면 thread가 독립적으로 처리할 수 있다. 한 dispatch에서 여러 attribute를 동시에 쓰는 것이 두 번 dispatch보다 훨씬 빠르다 — dispatch overhead와 SSBO 메타데이터 setup 비용이 한 번에 끝나기 때문.

GPU 입자가 풀 수 없는 것 — 한계

본 챕터의 모델로 표현 가능한 것:

표현이 까다로워지는 것:

GPU 입자의 본질적 한계는 element 간 의존성이다. 모든 입자가 모든 다른 입자에 의존하는 시뮬은 GPU의 좋은 case가 아니다 — 단, spatial hash로 의존성을 local로 제한하면 다시 GPU 친화적이 된다.

손작업 (Hands-on)

노드 버전 — Particle POP + Force Radial POP + Feedback POP

- Point Generator POP1 (입자 초기 위치 생성, Surface / Volume 옵션, Count: 5000)
- Particle POP1
- Force Radial POP1 (Attractor center: origin, Strength: 3.0, Falloff: 1.0)
- Feedback POP1 (Target: Particle POP1)
- Null POP1
- Geometry COMP1
- Render TOP1

권장 연결 (개략, 정확한 input 인덱스는 wiki/실험 확인):

Point Generator POP1 ──→ Particle POP1 (input 0: 초기 입자)
Feedback POP1 ──────────→ Particle POP1 (input N: 이전 frame 입자 상태)
Force Radial POP1 ──────→ Particle POP1 (input M: 외력)
Particle POP1 ──→ Feedback POP1 (Target)
Particle POP1 ──→ Null POP1 ──→ Geometry COMP1 ──→ Render TOP1

정확한 input slot 순서 및 attribute 명명 규칙(PartForce, vel)은 wiki의 Particle POP / Feedback POP / Force Radial POP 페이지를 직접 참고. 본 핸드북에서 보장할 수 있는 사실은 다음이다:

결과: 5000개의 입자가 origin attractor로 모이며 약간의 운동 에너지로 흩어졌다 모이기를 반복한다.

GLSL 버전 — 단일 GLSL POP

- Sphere POP1 (Primitive Type: Geodesic, Frequency: 5) — point만 사용, primitive는 무시.
- Text DAT_glsl
- GLSL POP1
- Geometry COMP1
- Render TOP1 (Point Style: Sprite / Sphere)

GLSL POP1 파라미터:

Text DAT_glsl:

// 입자 1-step. 본 dispatch가 매 프레임 한 번 돌고, 누적은 Ch.11 feedback에서.
// 외력 = 중력 + attractor(원점) + multiplicative drag.
void main() {
    const uint id = TDIndex();
    if (id >= TDNumElements()) return;

    vec3 p = TDIn_P();
    vec3 v = TDIn_vel();

    // 외력 누적
    vec3 g    = vec3(0.0, -9.81, 0.0);            // 중력
    vec3 atr  = -normalize(p) * 4.0;               // 원점 attractor (선형)
    vec3 acc  = g + atr;

    // semi-implicit Euler
    const float dt = 1.0 / 60.0;                   // 60Hz 가정
    v += acc * dt;
    v *= 0.99;                                     // multiplicative drag
    p += v * dt;

    P[id]   = p;
    vel[id] = v;
}

이 코드가 GPU에서 실제로 하는 일: 각 thread는 자기 인덱스의 입자 한 개를 책임진다. 입력 P, vel을 두 SSBO에서 읽는다. 중력 벡터와 origin attractor 벡터를 더해 가속도를 만든다. semi-implicit Euler로 vel을 먼저 갱신하고, 갱신된 vel을 써서 P를 갱신한다 (이 순서가 explicit보다 안정적). 0.99 곱은 점성 감쇠. 두 결과를 두 출력 SSBO에 동시에 쓴다. 모든 입자가 thread 의존성 없이 동시 갱신된다.

이 셰이더만으로는 시간이 흐르지 않는다 — 매 프레임 같은 입력 P, vel을 보기 때문에 매 프레임 같은 결과만 나온다. 이 셰이더의 출력 P, vel을 본 노드의 입력 P, vel로 되먹임해야 시간이 흐른다 (Ch.11).

Step 3 — curl noise 입자

위 셰이더의 외력에 curl noise를 더한다 (Ch.9의 curlNoise3 함수 재사용):

uniform float uTime;

vec3 curlNoise3(vec3 p) {
    const float eps = 0.01;
    float n_yp = TDSimplexNoise(p + vec3(0.0, eps, 0.0));
    float n_yn = TDSimplexNoise(p - vec3(0.0, eps, 0.0));
    float n_zp = TDSimplexNoise(p + vec3(0.0, 0.0, eps));
    float n_zn = TDSimplexNoise(p - vec3(0.0, 0.0, eps));
    float n_xp = TDSimplexNoise(p + vec3(eps, 0.0, 0.0));
    float n_xn = TDSimplexNoise(p - vec3(eps, 0.0, 0.0));
    float dndx = (n_xp - n_xn) / (2.0 * eps);
    float dndy = (n_yp - n_yn) / (2.0 * eps);
    float dndz = (n_zp - n_zn) / (2.0 * eps);
    return vec3(dndy - dndz, dndz - dndx, dndx - dndy);
}

void main() {
    const uint id = TDIndex();
    if (id >= TDNumElements()) return;

    vec3 p = TDIn_P();
    vec3 v = TDIn_vel();

    vec3 curl_f = curlNoise3(p * 0.8 + vec3(0.0, uTime * 0.1, 0.0)) * 3.0;
    vec3 atr    = -normalize(p) * 1.5;
    vec3 acc    = curl_f + atr;

    const float dt = 1.0 / 60.0;
    v += acc * dt;
    v *= 0.985;
    p += v * dt;

    P[id]   = p;
    vel[id] = v;
}

Vectors 페이지에 uTime (float, expression absTime.seconds) 추가.

이 코드가 GPU에서 실제로 하는 일: 매 입자에서 curl noise를 6 sample 호출(편미분 근사) + simplex noise 정상 호출 = 총 ~7번. 점이 10,000개면 70,000번/frame. NVIDIA 중급 GPU에서 1 ms 미만. curl이 divergence-free라 입자 밀도가 한쪽으로 뭉치지 않고 fluid처럼 흐른다. 동시에 약한 attractor로 전체가 한 영역에 머무른다.

Step 4 — Output Attributes에서 vel 빼기

vel을 Output Attributes에서 제거하면 셰이더 안의 vel[id] = v;가 컴파일 에러를 낸다. 출력에 등록한 attribute만 쓸 수 있다는 사실을 재확인.

또한 vel을 Create Attributes에서 등록하지 않은 채로 두면 TDIn_vel()도 정의되지 않는다 (입력에 없는 attribute는 읽을 수도 없다).

노드 ↔︎ GLSL 매핑

같은 attractor + drag 입자 시뮬레이션을 두 방식으로.

측면 Particle POP + Force Radial POP + Feedback POP GLSL POP (+ Feedback in Ch.11)
외력 추가 Force Radial POP 인스턴스 N개 셰이더에서 N항 누적
dt 내부 (frame rate) uniform
입자 lifespan / spawn Particle POP 파라미터 직접 구현 (age attribute)
외력 유형 radial / (다른 Force POP은 wiki 미확정) 임의 (curl, vector field sampler, mesh attractor, ...)
디버깅 viewer / info shader 컴파일 / printf 없음
확장성 노드 추가 셰이더 한 함수 추가

여기서 중요한 것: 노드 그래프는 "어떤 외력들이 합쳐지는가"를 시각적으로 보여준다. GLSL은 외력의 정확한 형태(falloff curve, 비선형 응답, 조건부 force)를 자유롭게 표현한다. 단순한 attractor + drag는 노드가 빠르다. SPH, boids, predator-prey, RD-coupled particle처럼 식이 복잡해지면 GLSL이 필수.

입자 렌더링 — point sprite, instanced mesh, ribbon

입자 시뮬은 갱신만으로 끝나지 않는다. 그려져야 한다. 본 챕터는 갱신에 집중하지만, 렌더링 선택지도 함께 짚는다.

  1. Point sprite: 각 입자가 한 픽셀 또는 작은 quad. 가장 싸다. millions of particles이 실시간 가능. Render TOP에서 Point Style을 Sprite로 두면 자동으로 활성. 색은 Cd attribute에서 옴.

  2. Instanced mesh: 각 입자에 한 mesh (sphere, cube, custom)를 instancing. mesh 1개의 draw call로 모든 입자 render. POP attribute가 instance attribute로 들어감. Ch.15 instancing에서 자세히.

  3. Ribbon / trail: 입자 궤적을 line strip이나 ribbon으로. age attribute와 trail buffer가 필요. POP inventory의 Trail POP이 도움 (N frame을 capturing).

  4. Volume splat: 각 입자가 volumetric blob. 3D texture에 splat 합성. fluid / smoke 시각화에 적합. 비싸지만 compositing 자유도 ↑.

본 챕터의 hands-on 코드는 Point sprite가 기본 표시.

입자 수 vs 디테일 — 미적 trade-off

미적 의도에 따라 입자 수와 mesh 디테일 사이의 trade-off가 다르다.

미적 의도 입자 수 입자별 mesh
안개 / 먼지 / 연기 백만 단위 point sprite (1픽셀)
비 / 눈 십만 단위 tiny billboard 또는 short line
불꽃놀이 수천 작은 glow sprite
군집 / 새 떼 수백 ~ 수천 low-poly mesh instancing
천체 / 메인 캐릭터 수십 full-detail mesh

GPU 시간 = (입자 수 × 입자당 작업). 둘의 곱이 일정 예산 아래여야 한다. 본 챕터의 GLSL POP은 갱신 비용만 다루지만, 렌더링 비용이 보통 갱신 비용보다 크다는 점을 알아두면 좋다.

Spawn 패턴 — 입자가 어디서 나오는가

본 챕터의 노드 setup에서 Point Generator POP은 입자의 초기 P를 결정한다. 이 POP의 분포 옵션이 spawn 패턴의 기초다.

POP inventory verbatim: "Point Generator POP — Creates N points randomly or in a pattern on the surface or within the volume of a shape." 또한 "Sprinkle POP"이라는 유사 generator도 있다: "Creates points either on the surface or within the volume of the input POP."

입자 시뮬레이션에서 흔한 디버깅 함정

  1. 입자가 한 곳에 다 모인다: drag가 너무 강하거나 attractor가 너무 강함. drag 감소 or attractor 약화.
  2. 입자가 화면 밖으로 폭발한다: dt가 너무 크거나 force가 너무 강함. semi-implicit Euler로 바꾸기, dt 줄이기, force clamp.
  3. 시뮬이 frozen된 듯 안 움직인다: feedback이 안 걸려있음 (Ch.11). 본 챕터 셰이더는 feedback 없이는 매 frame 같은 결과만 낸다.
  4. 첫 frame에 입자가 어디론가 튄다: vel이 초기화되지 않음. Create Attributes의 default 값 확인.
  5. frame rate에 따라 시뮬 결과가 다르다: dt를 1/60 고정으로 썼는데 실제 frame rate가 다름. 실제 frame time을 uniform으로 전달하는 패턴이 필요 (Ch.11).

확인 질문 (Self-check)

  1. 본 챕터의 GLSL 셰이더만으로는 시간이 흐르지 않는다. 정확한 이유를 한 문장으로.
  2. semi-implicit Euler가 explicit Euler보다 안정적인 이유는 무엇인가? 같은 dt와 같은 force에서 두 적분의 차이는?
  3. Output Attributes에 P와 vel을 동시에 등록하고 Output Access를 writeonly로 둘 때, 한 thread가 같은 frame 안에서 자기가 쓴 P를 다시 읽을 수 있는가? 없는가?
  4. curl noise 외력으로 움직이는 입자와 단순 random vector 외력으로 움직이는 입자는 시각적으로 어떻게 다른가? "밀도 보존"이 무슨 뜻인지 구체적인 관찰로 답하라.
  5. 입자가 100,000개일 때 본 챕터의 셰이더는 frame당 몇 번의 attribute read/write을 발생시키는가? (vel: 1 read + 1 write, P: 1 read + 1 write, 입자당 4번 → 400,000번). 같은 작업을 CPU에서 단일 thread로 하면 어느 정도 느려질까?

연결 고리

이 챕터의 한 줄 명제

입자 시뮬레이션은 attribute 갱신이 element 간 의존성 없이 동시 일어나는 가장 자연스러운 형태이며, 따라서 GPU의 가장 자연스러운 작업이다. 누적은 Ch.11이 맡는다.