이 핸드북의 핵심 명제 — "POP은 GPU 그래픽스 파이프라인의 데이터 모델을 노드 그래프로 노출시킨 학습 환경" — 가 이 챕터에서는 가장 직접적인 형태로 드러난다. Transform POP은 vertex shader의 MVP 곱셈 단계를 노드로 노출한 것이다. 4×4 행렬은 OpenGL, WebGPU, Unity, Houdini, RTR4 모두에서 같은 4×4 행렬이다. POP에서 그 곱셈을 직접 보고, 같은 행렬을 GLSL POP에서 손으로 곱해본다.
이 챕터에서 정복할 CG 개념
- Model / View / Projection 행렬의 역할 분담과 그 곱의 결과인 MVP.
- 동차좌표(homogeneous coordinates) — 왜 vec3가 아니라 vec4인가.
- 행렬 곱의 비가환성. T · R · S 와 S · R · T 는 다른 결과를 낸다.
- Translation을 표현하려면 4×4가 필요한 이유 — 3×3 행렬로는 원점을 옮길 수 없다.
- Normal에 (inverse-transpose)가 필요한 이유 — non-uniform scale에서 normal이 어떻게 왜곡되는지.
왜 이게 중요한가
학습자가 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에서의 노출 지점
- Transform POP (Transform / Layout POP). 인벤토리 verbatim: "Applies translate / rotate / scale to the P and N attributes of all input points." 즉 Transform POP은 P 만이 아니라 N도 함께 업데이트한다 — 이 사실은 vertex shader가 보통 직접 해야 하는 normal 보정을 Transform POP이 노드 차원에서 처리한다는 의미다.
- Copy POP (Transform / Layout POP). 입력에 transform을 적용해 N개의 복사본을 만들거나, 두 번째 입력의 각 포인트마다 한 번씩 복사한다. 즉 instance마다 다른 transform을 적용하는 가장 단순한 형태. 15장 instancing의 예고.
- GLSL POP / GLSL Advanced POP (Programmable POP). Matrices 페이지로 외부 행렬을 uniform으로 받아 shader 안에서 직접 곱한다. 이 챕터의 핵심.
- Matrices 페이지 (GLSL POP의 sequential parameter 블록).
matrix0name(uniform 이름),matrix0value(행렬 참조 — TouchDesigner의 어떤 matrix expression이든 받을 수 있다). wiki verbatim: "Sets a reference to a matrix to pass to the shader." - Geometry COMP의 transform. Geometry COMP는 자체 t, r, s 파라미터를 가지며, 이는 모든 내부 POP에 transform을 한 번 더 곱한 효과를 낸다. 이 transform을 추출해 GLSL POP에 넘기는 통로가 존재할 가능성이 있다 (예: Object CHOP 또는 expression
op('geo1').worldTransform). 정확한 expression 형태는 wiki 미확정 — 실험 필요.
이론
동차좌표
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 (Type: Geodesic, Frequency: 4)
- Normal POP1 (Type: Vertex)
- Transform POP1 (Translate: 1 0 0, Rotate: 0 45 0, Scale: 1 1 1)
- Geometry COMP1 → Render Simple TOP1
연결: Sphere POP1 → Normal POP1 → Transform POP1 → (Geometry COMP1 내부) → Render Simple TOP1
1단계: 행렬 곱의 순서를 직접 본다
- Transform POP1의 t=(1,0,0), r=(0,45,0), s=(1,1,1) 적용. 구가 오른쪽으로 1만큼 이동하고 y축 45도 회전.
- 같은 값을 Transform POP을 두 개로 분리해 본다 — Transform POP1에서는 t만, Transform POP2에서는 r만 적용. 순서를 바꿔 보면 결과가 다르다.
T then R: 먼저 오른쪽으로 옮긴 뒤 원점 기준 y축 회전 → 구가 원점 주변을 도는 위치로.R then T: 먼저 회전(원점은 그대로)한 뒤 옮김 → 구가 오른쪽 1에 그대로 있고 자체가 45도 회전.
- 보게 될 것: 같은 t, r, s 숫자가 순서에 따라 다른 장면을 만든다. 행렬 곱 비가환의 직접적 시각화.
2단계: N을 따라가는지 확인한다
- Render Simple TOP1로 결과를 본다. shading이 켜져 있으면 normal이 회전과 함께 잘 도는지 보인다.
- 비교 실험: Normal POP1을 Transform POP1 뒤로 옮긴다. 즉 Sphere → Transform → Normal → Render. 이 순서에서는 normal이 transform 적용 후 새로 계산된다.
- 원래 순서와 비교: Sphere → Normal → Transform → Render. 이 순서에서는 normal이 먼저 계산되고 Transform POP이 N을 함께 회전시킨다.
- 보게 될 것: 두 결과는 uniform scale + rotation만이라면 시각적으로 같다. non-uniform scale을 도입(s=(2, 1, 1))하면 차이가 보인다 — Transform POP이 normal을 어떻게 보정하는가가 드러난다.
3단계: Matrix CHOP / expression으로 동적 행렬
- Transform POP1의 t, r, s를 expression으로 묶어 시간에 따라 움직인다 (예: r y =
me.time.frame * 0.5). - 보게 될 것: 구가 매 프레임 회전. 같은 동작을 vertex shader 안의
uniform mat4한 줄로 표현하는 것이 다음 단계.
노드 ↔︎ GLSL 매핑
같은 결과를 GLSL POP 한 번의 dispatch로 만든다. Matrices 페이지로 외부 행렬을 uniform으로 받아 shader 안에서 직접 곱한다. 이것이 이 챕터의 핵심 매핑이다.
노드 구성
- Sphere POP1 (Type: Geodesic, Frequency: 4)
- Normal POP1 (Type: Vertex)
- GLSL POP1
- Attribute Class: Point
- Output Attributes:
P N(P와 N 모두를 쓰기 위해 둘 다 명시) - Number of Threads: Auto
- Matrices 페이지:
matrix0name = uModel,matrix0value =(어떤 mat4 expression이든 — 예:op('geo1').worldTransform, 또는 Transform CHOP의 출력, 또는 사용자가 손으로 만든 4×4 표).
- (선택) GLSL POP1을 Geometry COMP1에 넣고 Render Simple TOP1로 결과 확인.
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 행렬을 직접 곱한다
위 코드의 uModel을 uModel, 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 페이지로 받는 외부 행렬의 출처
- Geometry COMP의 transform:
op('geo1').worldTransform같은 expression. wiki 미확정 — 실험 필요: 이 expression의 정확한 형태가 mat4를 반환하는지, 별도 변환이 필요한지 확인 필요. - Camera COMP의 view 행렬: 카메라의 inverse transform. expression 또는 Object CHOP 경로.
- Camera COMP의 projection 행렬: 카메라의 projection setting에서 유도.
- 사용자가 만든 mat4: Matrix CHOP 같은 노드가 mat4를 만들면 그 reference를 Matrices 페이지에 넣을 수 있다 — wiki verbatim "Sets a reference to a matrix to pass to the shader."
확인 질문 (Self-check)
T * R * S * v와S * R * T * v의 결과가 다른 이유를 동차좌표 행렬로 설명할 수 있는가? 어느 쪽이 "스케일을 먼저 적용한다"인가?- 3×3 행렬로 translation을 표현할 수 없는 정확한 수학적 이유는? affine 변환이 선형이 아닌 이유는 무엇인가?
- uniform scale일 때 normal 변환에 inverse-transpose가 필요한가? 필요 없다면 그 이유는 무엇인가? non-uniform scale에서는 왜 필요한가?
- Transform POP이 N을 어떻게 업데이트하는지(단순 mat3 곱인가 inverse-transpose인가)를 실험으로 확인하려면 어떤 setup이 필요한가?
gl_Position = P * V * M * vec4(aPos, 1.0)을 POP 노드 그래프 위에서 "각 행렬마다 한 노드"로 분해하면 그래프는 어떤 모양이 되는가? 그 그래프가 보여주지 못하는 vertex shader의 측면은 무엇인가?
연결 고리
- LearnOpenGL — "Transformations" (https://learnopengl.com/Getting-started/Transformations). 행렬과 벡터, GLM, 행렬 곱의 비가환성을 가장 짧게 보여주는 페이지.
- LearnOpenGL — "Coordinate Systems" (https://learnopengl.com/Getting-started/Coordinate-Systems). Local / World / View / Clip / Screen space와 orthographic vs perspective projection의 정식 정리. 이 챕터의 직접적 책장(冊張).
- Real-Time Rendering 4ed §4 "Transforms". 동차좌표, 행렬 합성, normal 변환의 inverse-transpose 도출.
- Real-Time Rendering 4ed Appendix "Linear Algebra". 행렬 곱과 역행렬의 기본기. 위 §4를 따라가다 막히면 여기로.
- WebGPU Fundamentals (https://webgpufundamentals.org/). 같은 mat4 uniform 패턴이 WGSL에서 어떻게 적히는지의 비교. 행렬은 어디서나 같다.
이 챕터의 한 줄 명제
Transform POP은 vertex shader의 gl_Position = MVP * vec4(P, 1.0) 한 줄을 노드 그래프로 펼친 학습 장치이며, 같은 4×4 행렬은 OpenGL, WebGPU, Unity, Houdini 어디서나 같은 4×4 행렬이다.