11

Part IV · Multi-pass Chapter 11 of 18 Build 2025.31550

Feedback과 Ping-pong

“이전 프레임의 출력이 다음 프레임의 입력이 되는 패턴은 GPU에서 시간이 흐르는 거의 유일한 방법이다.”

이 챕터에서 정복할 CG 개념

왜 이게 중요한가

GPU에서 셰이더는 본질적으로 stateless다. 한 dispatch는 입력 buffer에서 읽어 출력 buffer에 쓴다. dispatch가 끝나면 출력 buffer는 다음 단계의 입력이 되거나 화면이 되거나 폐기된다. 셰이더 자체에는 "지난 frame"이 존재하지 않는다. 그럼에도 fluid simulation, reaction-diffusion, particle motion, video feedback art는 모두 시간 위에서 진행된다. 이 모순은 외부에서 frame-to-frame buffer를 유지하고, 매 frame 출력 buffer를 다음 frame의 입력으로 다시 묶어주는 것으로만 해결된다. POP은 이 묶음을 노드 그래프 위에 노출시킨다. 따라서 POP의 ping-pong을 익히면 LearnOpenGL의 framebuffer ping-pong, WebGPU의 storage buffer swap, Unity의 RenderTexture double-buffer가 모두 같은 패턴의 다른 이름임이 보인다.

POP에서의 노출 지점

TouchDesigner는 시간 누적을 두 가지 스케일로 노출한다.

  1. Feedback POP — 노드 그래프 레벨의 frame-to-frame feedback. Feedback POP1.target을 다운스트림의 어떤 POP으로 가리키면, 그 POP의 이전 cook 결과Feedback POP1의 출력으로 흘러나온다. 한 cook(=한 frame) 안에서는 그 흐름이 한 번만 일어난다. 따라서 입자가 매 frame 위치를 누적하는 식의 "느린" 시간성은 Feedback POP의 영역이다.
  2. GLSL POP의 Passes + Copy Previous Pass Output to Input — 한 cook 안에서 같은 셰이더를 N번 반복 실행. 시간이 흐르는 것은 아니지만, "한 frame 안에서 같은 update 식을 여러 번 적용"하는 GPGPU의 표준 iterative solver 패턴이다. wiki(Write a GLSL POP)는 multi-pass를 GLSL POP의 고유 기능으로 적시한다. GLSL Advanced POP의 parameter 페이지에도 npasses/prevpassoutput이 노출되지만 튜토리얼 문서와 충돌한다 — wiki 미확정 — 실험 필요. 이 챕터는 GLSL POP 쪽 경로에 집중하고, Advanced POP의 multi-pass는 Ch.12에서 다룬다.

세 번째 관련 노드는 Particle POP이다. Particle POP은 내부적으로 자체 feedback loop를 끌어안고 있어 외부 Feedback POP 없이도 입자 상태를 frame-to-frame 누적한다. 같은 ping-pong 패턴을 노드가 캡슐화한 형태로 이해하면 된다.

이론

Ping-pong buffer가 왜 두 개여야 하는가

같은 buffer에서 동시에 읽고 쓰면 thread 간 순서 보장이 없는 GPU에서는 결과가 정의되지 않는다. thread A가 element 5를 읽는 시점에 thread B가 element 5에 이미 새 값을 썼는지 아닌지 알 수 없다. 이 race condition을 피하는 가장 단순한 방법이 두 buffer를 두는 것이다. 모든 thread가 buffer A에서 읽고 buffer B에 쓴다. 다음 frame에서는 역할을 바꾼다. A→B, B→A, A→B, ... 이 "탁구"가 ping-pong의 이름이다.

frame 0:    [ A: state_0 ]  --read-->  shader  --write-->  [ B: state_1 ]
frame 1:    [ B: state_1 ]  --read-->  shader  --write-->  [ A: state_2 ]
frame 2:    [ A: state_2 ]  --read-->  shader  --write-->  [ B: state_3 ]
frame 3:    [ B: state_3 ]  --read-->  shader  --write-->  [ A: state_4 ]
            ...

GPU 입장에서 두 buffer는 두 개의 SSBO(Shader Storage Buffer Object)다. 매 frame 어느 쪽을 input으로 binding하는지만 바뀐다. POP은 이 binding swap을 Feedback POP 안에 숨긴다.

Mermaid: POP에서의 ping-pong 흐름

flowchart LR F[Feedback POP1
target = Update POP] --> U[Update POP
or GLSL POP1] U --> S[Skin/Render] U -. previous cook .-> F style F fill:#222,color:#eee style U fill:#225,color:#eee

Feedback POP1은 매 frame 시작 시점에 Update POP직전 cook 결과를 자신의 출력 슬롯에 채워 넣는다. Update POP은 그것을 이번 frame의 입력으로 받아 새 상태를 계산한다. cook이 끝나면 Update POP의 출력이 다음 frame을 위해 캡처된다. POP 그래프에는 cycle이 있는 것처럼 보이지만, 시간 축으로 펼치면 cycle이 아니라 시간 차이를 둔 단방향 흐름이다.

Multi-pass는 시간이 아니다

GLSL POP의 Passes = 4는 한 cook 안에서 셰이더를 4번 실행한다. Copy Previous Pass Output to Input이 켜져 있으면 pass i의 출력이 pass i+1의 입력으로 복사된다. wiki는 이 메커니즘(GPU 메모리 복사인지 buffer pointer swap인지, barrier 의미)을 명시하지 않는다 — wiki 미확정 — 실험 필요. 다만 사용자 입장에서의 의미는 분명하다. 4 pass는 "같은 frame 안에서 update equation을 4번 적용한 결과"이지 "4 frame이 흐른 결과"가 아니다. fluid solver의 Jacobi iteration이나 diffusion smoothing처럼 수렴을 위해 반복이 필요한 연산이 여기에 해당한다. 외부 Feedback POP의 시간 누적과는 스케일이 다르다.

Fluid와 Reaction-Diffusion

Stam의 stable fluids 알고리즘(GPU Gems Ch.38 Harris)은 velocity field와 pressure field를 텍스처(또는 SSBO)에 저장하고, 매 frame advection → diffusion → external force → projection 순서로 update 식을 돌린다. 매 단계가 ping-pong이다. Gray-Scott reaction-diffusion(Karl Sims)도 동일하다. concentration A, B 두 필드를 두 buffer에 저장하고, A의 새 값은 A의 옛 값 + Laplacian(A) + 반응항으로 계산한다. POP 도메인에서 RD는 점 집합 위의 attribute 갱신으로 옮길 수 있다 — 격자 점에 concA, concB attribute를 두고 Neighbor POP(Ch.13)로 이웃 평균을 구해 Laplacian을 근사하면 된다. update 식만 다를 뿐 buffer 토폴로지는 같다.

손작업 (Hands-on)

셋업 A — Particle POP + Feedback POP로 누적 운동

목적: 입자가 매 frame 위치를 조금씩 갱신해 누적적으로 움직이는 모습을 본다.

시작 파일

연결:

Sphere POP1 ─┐
             ├─> Particle POP1 ─> Null POP1 ─> (Geometry COMP → Render Simple TOP1)
Feedback POP1 ┘     ▲
                    │
                    └── target = Particle POP1

무엇을 보게 될 것인가: Particle POP1의 출력이 매 frame 같은 자리에 머무르지 않고, 직전 frame의 위치에서 force만큼 이동한 상태가 된다. Feedback POP1을 잘라내면 즉시 초기 위치로 고정된다. 시간이 끊어지는 것이다.

셋업 B — GLSL POP Passes = 1 + 외부 Feedback POP

목적: GLSL POP 안에 multi-pass를 두지 않고, 시간 누적을 모두 외부 Feedback POP으로 처리. 두 스케일을 손으로 분리하는 연습.

시작 파일

연결:

Grid POP1 ─┬─> GLSL POP1 ─> Null POP1
           │       ▲
Feedback POP1 ─────┘
   (target = GLSL POP1)

GLSL POP1의 Compute Shader DAT 내용:

// Attribute Class = Point, Output Attributes = P
// 입력 0: Grid POP1 (초기 위치)
// 입력 1: Feedback POP1 (직전 frame의 P)
uniform float u_dt;     // Vectors page에 float u_dt 추가, value = 1.0/60.0
uniform float u_speed;  // Vectors page에 float u_speed 추가, value = 0.5

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

    // 첫 frame에는 Feedback POP의 출력이 비어 있을 수 있으므로
    // 두 입력 중 시점 P를 안전하게 골라 쓴다.
    vec3 prev = TDIn_P(1u, id);          // 직전 frame 위치 (input 1)
    vec3 home = TDIn_P(0u, id);          // 초기 격자 위치 (input 0)

    // 미세한 회전 운동: 평면 위에서 home 기준 swirl
    vec3 r = prev - home;
    float a = u_speed * u_dt;
    mat2 R = mat2(cos(a), -sin(a), sin(a), cos(a));
    r.xy = R * r.xy;

    // 운동이 죽지 않도록 약한 perturbation
    vec3 next = home + r + vec3(0.001 * sin(prev.y * 7.0),
                                 0.001 * cos(prev.x * 7.0),
                                 0.0);
    P[id] = next;
}

이 코드가 GPU에서 실제로 무엇을 하고 있는가: 한 dispatch에 격자 점 4096개에 대해 thread 하나씩 배정된다. 각 thread는 input 1(직전 frame Feedback POP의 자기 자신 인덱스)에서 위치를 읽고, input 0(초기 Grid POP의 자기 자신 인덱스)에서 home 위치를 읽어 둘의 차이 벡터를 작은 각도만큼 회전시킨다. 결과는 출력 SSBO P의 같은 인덱스에 쓴다. 다음 frame에서 Feedback POP1이 이 출력을 입력 1로 다시 공급한다.

셋업 C — Reaction-Diffusion은 TOP feedback로 짧게

POP 도메인 RD는 셋업 B의 구조에 Neighbor POP을 추가해 Laplacian을 구한다(Ch.13에서 다룸). 가장 빠른 RD 첫 경험은 POP이 아닌 TOP 도메인이다: Feedback TOP + GLSL TOP으로 Gray-Scott을 돌리면 같은 ping-pong을 픽셀 위에서 본다. GPU Gems Ch.38(Harris)와 Karl Sims의 RD 페이지가 그 경로를 다룬다. 여기서 중요한 것: POP 위에서 같은 RD를 돌리려면 "픽셀"이 "점"으로 바뀔 뿐 update 식과 ping-pong 구조는 동일하다. 도메인만 바뀐다.

노드 ↔︎ GLSL 매핑

같은 결과 — "매 frame 점 위치를 swirl로 누적" — 를 두 가지 방식으로 만든다.

노드 방식

Grid POP1 ─> Transform POP1 (rotate Z by 1°) ─> Feedback POP1.target
                                                     ▲
                                            (Feedback POP1 → Transform POP1로 입력)

Feedback POP1.target = Transform POP1로 설정. 매 frame Transform POP1이 직전 frame의 회전 결과를 받아 또 1° 회전. 외부 시간 누적만으로 충분.

GLSL 방식 셋업 B의 코드 그대로. 한 노드 안의 단일 pass + 외부 Feedback POP. 차이는 단 하나: 노드 방식은 회전 행렬이 Transform POP의 parameter로 노출, GLSL 방식은 GLSL 코드 안의 R 행렬로 들어간다. 시간 누적의 메커니즘은 동일하게 외부 Feedback POP이다.

한 노드 안에 multi-pass를 두는 경우 Passes = 8로 설정하고 외부 Feedback POP을 제거하면 한 cook 안에서 8회 회전이 일어난다. 8 frame이 지난 것이 아니라 한 frame이 8 step 더 진행된 것이다. cook이 끝나면 상태는 초기화되어 다음 frame 다시 동일한 8회를 돈다. 시간 누적이 일어나려면 multi-pass가 외부 feedback과 결합되어야 한다. 이 분리가 Ch.11의 핵심이다.

확인 질문 (Self-check)

  1. Feedback POP을 그래프에서 제거하면 Particle POP의 결과는 어떻게 바뀌는가? 입자가 정지하는가, 사라지는가, 같은 자리에서 깜빡이는가?
  2. GLSL POP의 Passes = 4 + 외부 Feedback POP이 동시에 켜져 있을 때, 한 시간 단위(=1초) 동안 update 식이 몇 번 평가되는가?
  3. Ping-pong에서 buffer가 둘이 아니라 하나라면 어떤 시각적 artifact가 예상되는가? (race condition의 시각적 표현을 상상하기)
  4. Reaction-Diffusion을 TOP 도메인이 아닌 POP 도메인에서 돌려야 하는 합당한 이유 한 가지를 들어 보라.
  5. Feedback POP.target이 가리키는 POP의 출력 element 수가 frame 도중 변하면 어떤 일이 일어나는가? (Ch.12 Advanced POP의 element-count 변경과 연결)

연결 고리

이 챕터의 한 줄 명제

이전 frame의 출력이 다음 frame의 입력이 되는 ping-pong은 GPU에서 시간을 흐르게 하는 거의 유일한 방법이며, POP은 그것을 Feedback POP이라는 노드와 GLSL POP의 Passes 파라미터라는 서로 다른 두 시간 스케일로 노출한다.