06

Part II · Data Flow Chapter 06 of 18 Build 2025.31550

좌표계와 변환

“Transform POP은 vertex shader의 MVP 곱셈 단계를 노드로 노출한 것이다. 4×4 행렬은 어디서나 같다.”

이 핸드북의 핵심 명제 — "POP은 GPU 그래픽스 파이프라인의 데이터 모델을 노드 그래프로 노출시킨 학습 환경" — 가 이 챕터에서는 가장 직접적인 형태로 드러난다. Transform POP은 vertex shader의 MVP 곱셈 단계를 노드로 노출한 것이다. 4×4 행렬은 OpenGL, WebGPU, Unity, Houdini, RTR4 모두에서 같은 4×4 행렬이다. POP에서 그 곱셈을 직접 보고, 같은 행렬을 GLSL POP에서 손으로 곱해본다.

이 챕터에서 정복할 CG 개념

왜 이게 중요한가

학습자가 GLSL fragment shader는 자유롭게 쓰면서 vertex shader 앞에서 멈추는 경우가 흔하다. 이유는 거의 항상 "행렬이 어디서 와서 어디로 가는지" 모르기 때문이다. POP의 Transform POP은 그 행렬 곱을 가장 단순화한 형태로 노출한다. t, r, s 세 파라미터를 만지면 그 즉시 Model 행렬이 만들어지고, attribute의 P와 N에 적용된다. 같은 행렬이 LearnOpenGL의 "Coordinate Systems" 페이지에서는 glm::translate(...) * glm::rotate(...) * glm::scale(...)로 작성되고, Unity에서는 Transform.localToWorldMatrix로, WebGPU에서는 mat4x4<f32> uniform으로 등장한다. 행렬은 어디서나 같다.

POP에서의 노출 지점

이론

동차좌표

3D 점 (x, y, z)를 4D 벡터 (x, y, z, 1)로 확장한다. 방향벡터(direction)는 (x, y, z, 0)로 쓴다. w=1이면 translation이 적용되고, w=0이면 적용되지 않는다. 이 한 줄의 약속이 모든 차이를 만든다.

점:      [x y z 1]
방향:    [x y z 0]

T = | 1 0 0 tx |
    | 0 1 0 ty |
    | 0 0 1 tz |
    | 0 0 0  1 |

T * [x y z 1]^T = [x+tx, y+ty, z+tz, 1]^T   (translation 적용)
T * [x y z 0]^T = [x,    y,    z,    0]^T   (translation 무시)

3×3 행렬로는 첫 번째 효과를 만들 수 없다. 원점을 옮기려면 affine 변환이 필요하고, affine 변환을 선형 형태로 적기 위한 트릭이 동차좌표다.

Model / View / Projection

                                model space (POP의 P attribute가 사는 좌표계)
                                       |
                                       v
                            M (Model 행렬: t·R·S)
                                       |
                                       v
                                world space
                                       |
                                       v
                            V (View 행렬: 카메라의 inverse)
                                       |
                                       v
                                view space (camera space)
                                       |
                                       v
                            P (Projection 행렬: orthographic 또는 perspective)
                                       |
                                       v
                                clip space ([-1,1]^3 NDC로 가는 직전)
                                       |
                                       v
                              perspective divide (w로 나눔)
                                       |
                                       v
                                NDC ([-1,1]^3)
                                       |
                                       v
                            viewport transform
                                       |
                                       v
                                screen space (픽셀 좌표)

vertex shader가 매 정점마다 하는 일은 이 사슬의 첫 세 단계, 즉 gl_Position = P * V * M * vec4(aPos, 1.0)다. clip space까지를 책임지고, 그 다음 단계(perspective divide, viewport)는 fixed function이 처리한다.

행렬 곱은 비가환

A * B != B * A. Transform POP의 t·r·s 순서는 "스케일하고 → 회전하고 → 이동한다"로 읽힌다. 행렬 표기상 T * R * S * v다. 같은 t, r, s 값을 적용해도 S·R·T 순서면 다른 결과가 나온다 (translation이 회전과 스케일에 의해 변환되기 때문). LearnOpenGL "Transformations" 페이지가 GLM 코드와 함께 이 사실을 명시한다.

Normal에 inverse-transpose가 필요한 이유

P에 적용하는 행렬 M에 non-uniform scale이 들어있으면(예: x를 2배, y를 1배), 같은 행렬을 N에 적용하면 N이 표면에 더 이상 수직이 아니게 된다. 정확한 보정 행렬은 transpose(inverse(M))다. uniform scale + rotation만이라면 보정 없이 그냥 M (또는 그 3×3 부분)을 써도 무방하다. RTR4 §4가 이 사실을 도출한다. Transform POP은 인벤토리에 따르면 P와 N을 모두 업데이트하므로 — 즉 normal 보정을 내부에서 처리한다고 추정되나 — 정확한 보정 정책(항상 inverse-transpose인가, uniform scale에 한해 단순 M^T인가)은 wiki 미확정 — 실험 필요.

손작업 (Hands-on)

시작 파일

연결: Sphere POP1 → Normal POP1 → Transform POP1 → (Geometry COMP1 내부) → Render Simple TOP1

1단계: 행렬 곱의 순서를 직접 본다

2단계: N을 따라가는지 확인한다

3단계: Matrix CHOP / expression으로 동적 행렬

노드 ↔︎ GLSL 매핑

같은 결과를 GLSL POP 한 번의 dispatch로 만든다. Matrices 페이지로 외부 행렬을 uniform으로 받아 shader 안에서 직접 곱한다. 이것이 이 챕터의 핵심 매핑이다.

노드 구성

Compute Shader (DAT)

// GLSL POP, Attribute Class = Point, Output Attributes = P, N
// Matrices 페이지: uModel (mat4) 선언됨 — POP이 자동으로 uniform mat4 uModel을 주입한다.

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

    // 입력 P를 동차좌표로 확장.
    vec4 wp = uModel * vec4(TDIn_P(), 1.0);

    // P를 새 위치로 기록.
    P[id] = wp.xyz;

    // Normal에는 inverse-transpose의 3x3 부분을 곱한다.
    // uniform scale + rotation만이라면 mat3(uModel)만으로도 동작하지만,
    // 일반 case를 위해 inverse-transpose를 사용한다.
    mat3 normalMat = transpose(inverse(mat3(uModel)));
    N[id] = normalize(normalMat * TDIn_N());
}

이 코드가 GPU에서 실제로 무엇을 하고 있는가: Sphere POP의 모든 포인트에 대해 한 thread가 할당된다. 각 thread는 자기 점의 P를 (x, y, z, 1)로 확장하고 uModel을 곱해 world space P를 얻는다. 동시에 같은 점의 N에는 uModel의 inverse-transpose(3×3 부분)를 곱하고 정규화한다. 출력은 SSBO 두 개 — P[] 와 N[] — 에 thread당 한 번씩 쓰인다. Transform POP이 노드 내부에서 하는 일과 수학적으로 동등하다.

Advanced POP 버전 (Point 클래스, 함수명만 다르다)

// GLSL Advanced POP, Output Attributes (Point page): P, N
// Matrices 페이지: uModel
// Advanced POP은 출력 변수에 oTDPoint_ 접두사를 사용한다.

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

    vec4 wp = uModel * vec4(TDInPoint_P(), 1.0);
    oTDPoint_P[id] = wp.xyz;

    mat3 normalMat = transpose(inverse(mat3(uModel)));
    oTDPoint_N[id] = normalize(normalMat * TDInPoint_N());
}

이 코드가 GPU에서 실제로 무엇을 하고 있는가: 위와 정확히 동일한 작업이다. 다른 점은 (1) input 함수가 TDInPoint_P()처럼 클래스 접두사를 가지며, (2) output 변수가 oTDPoint_P[]처럼 oTDPoint_ 접두사를 가진다는 것뿐이다. Advanced POP은 같은 셰이더 안에서 Point, Vertex, Primitive 세 클래스를 동시에 읽고 쓸 수 있기 때문에 이름의 모호성을 피하려 접두사가 들어간다. 이 코드는 Point 클래스만 다루므로 단순 GLSL POP과 결과가 완전히 같다.

단계별 분리 — M, V, P 행렬을 직접 곱한다

위 코드의 uModeluModel, uView, uProj 세 개로 분리해 vertex shader 그 자체와 같은 모양을 만들 수도 있다. Matrices 페이지에 세 개의 sequential 블록을 만들고 각각 mat4 expression을 묶는다. Shader는 다음과 같이 된다 — 단, Render TOP을 안 쓰고 POP attribute로 clip space에 가버리면 화면에 어떻게 표현될지는 의미가 없어진다는 점에 주의. 학습 목적의 진단 코드라면:

// 진단용 — POP attribute P를 clip space 좌표로 채워서, 결과 POP을 다른 도구로 볼 때
// vertex shader가 평소 하던 일을 직접 봤다고 말할 수 있는 형태.

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

    vec4 clipPos = uProj * uView * uModel * vec4(TDIn_P(), 1.0);

    // perspective divide까지 적용하고 싶다면:
    // clipPos.xyz /= clipPos.w;

    P[id] = clipPos.xyz;
    N[id] = TDIn_N(); // 진단용이라 normal은 그대로 둠.
}

이 코드가 GPU에서 실제로 무엇을 하고 있는가: vertex shader가 한 줄로 하는 gl_Position = uProj * uView * uModel * vec4(aPos, 1.0)을 POP의 P attribute에 직접 적기. 결과 POP을 Render TOP으로 다시 그리면 이중 변환이 되므로 시각적으로 유용하진 않지만, GLSL POP과 vertex shader가 같은 행렬 곱을 같은 형태로 한다는 사실을 한 셰이더 안에서 확인할 수 있다. 학습용 데모로만 사용한다.

Matrix 페이지로 받는 외부 행렬의 출처

확인 질문 (Self-check)

  1. T * R * S * vS * R * T * v의 결과가 다른 이유를 동차좌표 행렬로 설명할 수 있는가? 어느 쪽이 "스케일을 먼저 적용한다"인가?
  2. 3×3 행렬로 translation을 표현할 수 없는 정확한 수학적 이유는? affine 변환이 선형이 아닌 이유는 무엇인가?
  3. uniform scale일 때 normal 변환에 inverse-transpose가 필요한가? 필요 없다면 그 이유는 무엇인가? non-uniform scale에서는 왜 필요한가?
  4. Transform POP이 N을 어떻게 업데이트하는지(단순 mat3 곱인가 inverse-transpose인가)를 실험으로 확인하려면 어떤 setup이 필요한가?
  5. gl_Position = P * V * M * vec4(aPos, 1.0)을 POP 노드 그래프 위에서 "각 행렬마다 한 노드"로 분해하면 그래프는 어떤 모양이 되는가? 그 그래프가 보여주지 못하는 vertex shader의 측면은 무엇인가?

연결 고리

이 챕터의 한 줄 명제

Transform POP은 vertex shader의 gl_Position = MVP * vec4(P, 1.0) 한 줄을 노드 그래프로 펼친 학습 장치이며, 같은 4×4 행렬은 OpenGL, WebGPU, Unity, Houdini 어디서나 같은 4×4 행렬이다.