02

Part I · Foundations Chapter 02 of 18 Build 2025.31550

Attribute의 세계

“GPU 위 mesh는 vertex buffer + index buffer의 쌍이며, Point/Vertex/Primitive 분리는 셰이더 입출력 구조 그 자체다.”

이 챕터에서 정복할 CG 개념

왜 이게 중요한가

이 분리는 셰이더 입출력 구조 그 자체다. GLSL vertex shader의 in 변수가 어떤 단위로 호출되는지, fragment shader의 flat qualifier가 왜 필요한지, normal map과 tangent space가 왜 vertex 단위로 정의되는지 — 이 모든 질문은 Point / Vertex / Primitive 중 어디에 attribute가 살고 있느냐의 결과다. POP은 이 결정을 노드 메뉴로 노출해 학습자가 "normal을 어디에 둘 것인가"라는 한 번의 클릭이 화면의 음영을 어떻게 바꾸는지 직접 만지게 한다. 같은 결정이 LearnOpenGL "Hello Triangle"에서는 EBO(Element Buffer Object)를 만드는 코드 줄로, RTR4 §3에서는 "vertex specification"이라는 용어로, Houdini에서는 attribute promote SOP로 등장한다. 모두 같은 개념이다.

이 챕터에서 attribute는 영어 그대로 표기한다.

POP에서의 노출 지점

이론

point buffer와 index buffer

GPU 위에서 삼각형 mesh는 두 개의 버퍼로 표현된다.

point buffer (per-point attributes)
  ┌─────────────────┬────────────────┬────────────────┐
  │  P[0] = (x,y,z) │  N[0] = (...)  │  Cd[0] = (...) │
  │  P[1] = (x,y,z) │  N[1] = (...)  │  Cd[1] = (...) │
  │  P[2] = (x,y,z) │  N[2] = (...)  │  Cd[2] = (...) │
  │  ...            │  ...           │  ...           │
  └─────────────────┴────────────────┴────────────────┘

index buffer (per-vertex point references)
  ┌────────┬────────┬────────┬────────┬────────┬────────┐
  │  v0=2  │  v1=5  │  v2=1  │  v3=2  │  v4=1  │  v5=8  │ ...
  └────────┴────────┴────────┴────────┴────────┴────────┘
     △        △        △        △        △        △
     └─── prim 0 ──────┘        └─── prim 1 ──────┘

primitive: 3개의 연속된 index가 한 삼각형

point buffer는 점 하나당 한 행이다. P(position), N(normal), Cd(color), Tex(texcoord) 같은 per-point attribute가 같은 인덱스의 같은 행에 함께 산다. index buffer는 vertex 단위로 점을 가리킨다. 한 삼각형은 연속된 세 인덱스다.

같은 위치를 가진 점을 N개 저장하지 않는 것이 indexed rendering의 핵심이다. 정육면체는 모서리 8개의 위치만으로 12개의 삼각형을 구성한다. point buffer의 행 수는 8, index buffer의 길이는 36이다.

POP의 세 attribute class

POP의 분류는 이 두 버퍼와 일대일이다.

이 셋은 동일한 mesh를 서로 다른 단위로 분할한다. point가 가장 적고, primitive는 point/3 정도이며, vertex는 primitive×3이다. POP의 어떤 노드 출력을 Info 패널로 열면 세 숫자가 항상 같이 표시된다.

Sphere POP (Geodesic, Frequency 4)
  Points     : 162
  Vertices   : 960   (320 triangles × 3)
  Primitives : 320

flat shading vs smooth shading의 분기점

normal vector를 어느 class에 두느냐가 라스터화 결과를 결정한다.

smooth shading: normal이 point class에 산다. 한 점을 공유하는 인접 면들이 같은 점 인덱스를 참조하므로, vertex shader가 그 점을 읽으면 같은 normal을 받는다. fragment shader로 보간되어 들어가는 normal은 면을 가로질러 부드럽게 변한다. 보통 인접 면들의 face normal을 평균낸 값을 점에 넣어 만든다.

flat shading: normal이 vertex class 또는 primitive class에 산다. 같은 점이 두 면에 공유되더라도 corner별로 서로 다른 normal을 갖게 할 수 있다. fragment shader는 면 안에서 일정한 normal을 받아 면 단위로 음영이 끊긴다. Facet POP으로 점을 분리한 뒤 면별 normal을 vertex class에 쓰는 흐름이 가장 일반적이다.

여기서 직관과 어긋나는 부분: "flat shading은 더 단순해서 더 가벼울 것"이라는 추측은 메모리 측면에서 틀리다. 점을 공유 못 하기 때문에 point buffer가 N배로 늘어난다. vertex shader 호출 수도 늘어난다. flat shading은 시각 효과의 선택이지 최적화의 선택이 아니다.

Sphere POP — Geodesic vs Rows/Columns

같은 구도 두 가지 점 구조로 만들 수 있다.

두 경우 점/정점/프리미티브의 수가 다르다. 같은 "구"라는 형태가 메모리에서는 다른 두 가지 데이터로 존재한다는 사실이 이 챕터의 첫 번째 충격이어야 한다.

손작업 (Hands-on)

시작 파일

연결: Sphere POP1 → Normal POP1 → Render Simple TOP1

단계 1 — 점/정점/프리미티브 수 확인

Sphere POP1의 Info 패널을 연다. Points, Vertices, Primitives의 세 숫자를 확인한다. Geodesic Frequency 3에서 점 수는 약 92, vertex 수는 약 540, primitive 수는 약 180 근처가 보인다. 정확한 수치는 빌드에 따라 차이가 있을 수 있으므로 자신의 화면에서 직접 확인한다.

Type을 Rows/Columns로 바꾼다. 같은 모양이지만 세 숫자가 모두 달라진다.

단계 2 — Normal POP의 class 변경

Normal POP1의 Class 메뉴를 Point로 설정한다(메뉴 라벨은 wiki 미확정 — 실험 필요, point/vertex/primitive 셋 중 point에 해당하는 항목). Render Simple TOP1의 출력 화면에서 구의 음영이 면을 가로질러 부드럽게 변하는 것을 본다. smooth shading이다.

Class를 Vertex로 바꾼다. 같은 구가 면 단위로 끊긴 다면체처럼 보인다. flat shading이다. 점 수와 vertex 수는 같지만(Normal POP은 점 수를 바꾸지 않는다), normal이 vertex slot에 저장되어 같은 점에 인접한 면들이 서로 다른 normal을 본다.

단계 3 — Facet POP으로 점 분리

Normal POP1과 Render Simple TOP1 사이에 Facet POP1을 끼우거나, Sphere POP1과 Normal POP1 사이에 끼운다. 위치에 따라 결과가 다르다는 사실 자체가 관찰 포인트다. Facet POP1의 "점 공유" 토글을 끄면 모든 면이 독립적인 점 세트를 갖는다. point 수가 vertex 수와 같아진다. 이 상태에서 Normal POP1을 Point class로 두어도 flat처럼 보인다 — 점이 분리되어 더 이상 공유되지 않기 때문이다.

단계 4 — Topology POP의 존재 확인

Topology POP1을 Sphere POP1 뒤에 연결만 해본다. 파라미터는 건드리지 않는다. Info 패널의 숫자가 변하지 않는다는 사실만 확인한다. Topology POP은 메모리 할당의 미세 제어이며, 일반적인 사용에서는 직접 만질 일이 드물다. Ch.12에서 GLSL Advanced POP과 함께 다시 등장한다.

노드 ↔︎ GLSL 매핑

위 단계 2의 결과 — point class normal로 smooth shading — 를 GLSL POP으로 직접 구현한다. 인접 면의 face normal을 평균내 점에 쓰는 흐름이다.

// GLSL POP
// Attribute Class      : point
// Number of Threads    : Auto
// Output Attributes    : N
// 입력: Sphere POP의 P, 그리고 인접 vertex 정보를 가져올 수 있도록 같은 입력에 연결
//
// 주: per-point normal을 face normal의 평균으로 직접 구성하는 의사적 흐름.
// 한 점이 속한 모든 vertex 슬롯과 그 vertex가 속한 primitive의 법선을 누적하는 코드는
// 실제 구현에서는 보조 자료구조(역참조)를 요구한다. 여기서는 가장 단순한 형태로,
// 미리 계산된 primitive face normal을 primitive 입력으로 받아 평균낸다고 가정한다.

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

    // 현재 점의 위치
    vec3 p = TDIn_P();

    // 점이 속한 인접 primitive의 face normal을 평균낸다.
    // 실제로는 별도의 인접성 attribute가 필요하다. 여기서는 점 위치를 단위 벡터로
    // 보는 것이 구에 한해 정답이라는 사실을 이용해 간단한 등가 결과를 만든다.
    vec3 n = normalize(p);

    N[id] = n;
}

이 코드가 GPU에서 실제로 무엇을 하고 있는가. 첫째, TDIndex()로 현재 thread가 처리할 점의 1차원 인덱스를 받는다. 둘째, workgroup 크기로 라운드업된 잉여 thread는 if (id >= TDNumElements()) return;로 즉시 빠져나가 SSBO 범위 밖 쓰기를 막는다. 셋째, TDIn_P()로 입력 0번 POP의 P attribute를 같은 인덱스에서 읽는다. 넷째, 구의 점은 원점을 향한 단위 벡터가 곧 normal이라는 기하학적 사실을 이용해 normalize(p)를 결과로 쓴다. 다섯째, N[id] = n으로 출력 N attribute의 같은 슬롯에 쓴다. point class이므로 N은 point buffer의 같은 행에 저장된다. 결과는 Normal POP을 Point class로 설정한 것과 시각적으로 동일하다.

flat shading 버전은 다음과 같이 다르다.

// GLSL Advanced POP
// Shader Dispatch Mode : single
// Number of Threads    : Per Input Vertex
// Vert Output Attributes : N
//
// vertex class normal을 직접 쓴다. 각 vertex slot이 자기가 속한 primitive의 face normal을 받는다.

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

    // 이 vertex가 속한 primitive id와 그 안에서의 corner 인덱스를 결정하는
    // helper는 wiki 미확정 — 실험 필요. 여기서는 primitive normal이 별도로
    // 계산되어 primitive attribute로 들어와 있다고 가정한다.
    uint primId = id / 3;   // 삼각형 가정. quad mesh에서는 4.
    vec3 fn = TDInPrim_N(0, primId, 0);

    oTDVert_N[id] = fn;
}

이 코드는 vertex 단위 thread로 dispatch되며, 각 thread가 자기 vertex slot에 속한 primitive의 face normal을 그 slot에 쓴다. 결과적으로 같은 점을 공유하는 두 면이 자기 corner slot에 서로 다른 normal을 갖는다. 라스터화 단계에서 fragment shader가 보간하는 normal은 면 안에서만 일정하므로 면 경계에서 끊긴다. flat shading의 메모리 표현이다.

두 구현은 입력은 같지만 어느 SSBO에 쓰느냐가 다르다. 노드로 한 번, GLSL로 한 번 — 두 번 만나본 뒤에 학습자는 LearnOpenGL의 "vertex shader input"이라는 단어가 어떤 메모리에서 길어 올려지는지 본다.

확인 질문 (Self-check)

  1. Sphere POP의 Type을 Geodesic에서 Rows/Columns로 바꾸면 점/정점/프리미티브 수 중 어느 것이 어떻게 변하는가? 같은 형태인데 왜 변하는가?
  2. Facet POP으로 점을 분리한 mesh의 point buffer 크기는 분리 전과 비교해 얼마나 늘어나는가? 그 늘어남이 flat shading의 시각적 효과와 어떻게 연결되는가?
  3. Normal POP의 출력 class를 Vertex로 두면, 같은 point를 공유하는 두 면이 자기 corner에 서로 다른 normal을 가질 수 있다. 이것이 왜 flat shading의 정의인가?
  4. indexed rendering에서 vertex shader 호출 수는 point buffer의 길이를 따르는가, index buffer의 길이를 따르는가?
  5. POP의 Vertex class는 OpenGL 용어의 "vertex"와 정확히 일치하지 않는다. 두 용어의 차이를 한 줄로 적을 수 있는가?

연결 고리

이 챕터의 한 줄 명제

GPU 위 mesh는 point buffer와 index buffer의 쌍이며, POP의 Point / Vertex / Primitive 분리는 셰이더 입출력 구조 그 자체이고, 어느 클래스에 normal을 두느냐가 flat과 smooth를 가른다.