03

Part I · Foundations Chapter 03 of 18 Build 2025.31550

Custom Attribute 설계

“데이터를 “어느 클래스에 둘 것인가”는 GPU 프로그램의 첫 번째 설계 결정이다.”

이 챕터에서 정복할 CG 개념

왜 이게 중요한가

GPU 프로그램의 첫 번째 설계 결정은 "이 데이터를 어디에 둘 것인가"다. velocity를 점에 두면 점 하나당 vec3 하나, 점이 100만 개면 12MB(float3 기준)다. velocity를 vertex에 두면 같은 점을 공유하는 면들마다 다른 속도를 줄 수 있지만 메모리는 N배가 된다. velocity를 primitive에 두면 면 하나당 하나 — 점 단위 시뮬레이션에는 무용지물이다. 이 결정을 한 번 잘못하면 그 위에 쌓는 모든 GLSL POP이 어색해진다.

같은 결정이 Houdini의 "Geometry attributes" 문서에서는 attribute class 표("point / vertex / primitive / detail")로 등장하고, Unity DOTS에서는 IComponentData가 어느 entity archetype에 붙느냐로 등장하고, WebGPU에서는 storage buffer의 binding 그룹 설계로 등장한다. POP은 이 결정을 노드 하나(Attribute POP) 위에 메뉴 하나로 노출시킨다. 그 메뉴를 만지는 동안 학습자는 GPU 데이터 모델의 첫 설계 결정을 손으로 해본다.

이 챕터에서 attribute는 영어 그대로, "어트리뷰트"라는 한국어 표기와 혼용하지 않는다. 다른 챕터에서는 한국어 표기를 쓸 수 있으나, 이 챕터 안에서는 영어 표기로 통일한다.

POP에서의 노출 지점

이론

custom attribute의 정의

POP에서 attribute는 SSBO의 한 컬럼이다. 이름(예: vel), 타입(float / vec2 / vec3 / vec4 / int / uint / matrix 등), 컴포넌트 수, 그리고 어느 class에 속하는지로 정의된다. P(position), N(normal), Cd(color), Tex(texcoord), Pscale 등 일부는 표준 이름이지만, 그 외에는 모두 사용자가 만든다.

vel이라는 이름의 float3 attribute를 point class로 만들면 GPU에 다음 형태의 SSBO가 추가된다.

vel SSBO (point class, float3)
  ┌─────────────────────┐
  │ vel[0] = (vx,vy,vz) │
  │ vel[1] = (vx,vy,vz) │
  │ vel[2] = (vx,vy,vz) │
  │ ...                 │
  └─────────────────────┘
  length = point count

같은 이름을 vertex class로 만들면 SSBO의 길이가 vertex count로 바뀐다. primitive class면 primitive count.

per-element sim state의 의미

입자 시뮬레이션의 한 스텝은 보통 이렇게 표현된다.

v[i] += a[i] * dt
p[i] += v[i] * dt

i는 입자 인덱스, v는 velocity, a는 acceleration, p는 position. 이 식이 GPU 친화적인 이유는 i 사이에 의존성이 없다는 점이다. thread 하나가 인덱스 i 하나를 맡아 위 두 줄을 실행하면 끝난다. POP에서 vp를 point class attribute로 두면 SSBO 두 개가 점 수만큼의 길이로 GPU 메모리에 산다. compute shader는 thread당 한 점씩 위 식을 돌린다.

이것이 Ch.10의 입자 시뮬레이션의 전체 구조다. 이 챕터는 그 구조를 만들기 위한 첫 발걸음 — attribute 하나를 만들어 점에 붙이는 일 — 만 다룬다.

class 선택의 결과

선택 메모리 접근 패턴 적절한 경우
Point point 수만큼 thread 1개 = 점 1개. 가장 자연스러운 dispatch. 입자 위치/속도, 점 단위 시뮬레이션.
Vertex vertex 수만큼 (= primitive count × verts-per-prim) corner 단위 처리. 같은 점에서 면마다 다른 값. per-corner texcoord, flat shading용 normal.
Primitive primitive 수만큼 삼각형 단위. face id, primitive group, face color.

velocity를 vertex에 두는 결정은 거의 언제나 잘못된 결정이다. 같은 점이 여러 vertex slot에서 서로 다른 속도를 갖는 mesh는 시뮬레이션 다음 스텝에서 점 위치가 한 값으로 결정될 수 없다. velocity는 거의 항상 point class다.

반대로 정사면체에 "이 면은 트라이앵글 인덱스 몇 번?"같은 메타 정보를 attribute로 두고 싶다면 primitive class가 정답이다. 점에 두면 같은 점을 공유하는 두 면이 같은 값을 강제로 받는다.

attribute promote와 convert

Houdini의 Attribute Promote SOP에서 익숙한 동작이다. point에 있는 attribute를 primitive로 promote하면 각 primitive 내 점들의 값을 평균(또는 최댓값, 최솟값, 첫 번째)으로 reduce한다. 반대 방향(primitive → point)에서는 한 점에 인접한 모든 primitive의 값을 reduce한다.

POP의 Attribute Convert POP은 이 변환을 노드 하나로 제공한다. 위키 요약상 변환 방향과 reduce 방식의 정확한 메뉴 항목은 wiki 미확정 — 실험 필요. 본문에서는 "클래스 사이 변환은 reduce를 동반한다"는 사실만 짚는다.

데이터 단위가 바뀐다는 사실

이 챕터의 핵심은 다음 한 줄로 압축된다. attribute의 class 변환은 같은 데이터를 다른 단위로 재해석하는 작업이다. 한 점에 한 값이 붙어 있던 attribute를 vertex class로 promote하면 같은 값이 N개로 복제될 수도 있고, 평균을 통해 1개로 reduce될 수도 있다. 이 변환은 비용 없는 재해석이 아니다. GPU 메모리 안에서 실제로 새 SSBO가 할당되고 채워진다.

손작업 (Hands-on)

시작 파일

연결: Sphere POP1 → Attribute POP1 → Random POP1 → Math POP1 → Render Simple TOP1

단계 1 — vel이라는 점 attribute를 만든다

Attribute POP1을 선택한다. New Attribute 시퀀스 블록에서 다음을 설정한다.

Attribute POP1의 Info 패널에서 vel attribute가 point class로 추가된 것을 확인한다. 이 단계의 관찰 포인트는 단 하나 — point buffer에 새 컬럼이 하나 늘었다는 사실이다.

단계 2 — Random POP으로 vel을 채운다

Random POP1을 선택한다. 대상 attribute로 vel을 지정한다. 결과를 set 모드로 두고 범위를 -0.5에서 0.5 정도로 설정한다. Render Simple TOP1에서 보이는 구는 아직 그대로다. 점은 움직이지 않았다. velocity는 단지 점이 가진 새 attribute일 뿐 위치에 더해지지 않았기 때문이다.

단계 3 — Math POP으로 P에 vel을 더한다

Math POP1을 설정한다. 입력 attribute로 P와 vel을 사용하고, 결과를 P = P + vel * 0.1처럼 출력한다. 정확한 파라미터 라벨은 Math POP의 시퀀스 블록 구성을 따른다 — operation을 "add", 두 번째 피연산자에 vel, 스케일러 0.1. Render Simple TOP1에서 구가 약간 부풀어 보인다. 점마다 서로 다른 무작위 방향으로 0.1만큼 밀려났다.

이 시점에 학습자는 첫 attribute가 점에 붙는 순간을 체험했다. 시간이 흐르는 시뮬레이션은 아직 아니다(매 프레임 vel을 사용하지만 vel 자체가 갱신되지 않는다). 그 갱신은 Ch.11의 feedback POP에서 다룬다.

단계 4 — Attribute Convert POP으로 클래스를 옮긴다

Math POP1 다음에 Attribute Convert POP1을 끼우고 vel을 vertex class로 변환한다. Info 패널에서 vel이 vertex class로 옮겨졌고 SSBO 길이가 vertex count로 바뀐 것을 본다. 이 변환의 결과로 같은 점이 서로 다른 vertex slot에서 다른 vel을 가질 수 있게 됐지만, 시각적으로는 아무것도 바뀌지 않는다 — Math POP은 이미 P를 갱신한 뒤이고, 그 이후 vel은 더 이상 쓰이지 않기 때문이다. 메모리 모양만 바뀌었다. 이것이 attribute promote / convert의 비용을 직관적으로 보여주는 자리다.

노드 ↔︎ GLSL 매핑

위 손작업 전체(Attribute POP → Random POP → Math POP)를 GLSL POP 한 노드에서 한 번에 처리할 수 있다. Random POP의 "임의 vel 채우기" 부분은 GLSL에서 hash 함수로 대체한다.

// GLSL POP
// Attribute Class      : point
// Number of Threads    : Auto
// Output Attributes    : P, vel
// Create Attributes    : vel (float3, default 0)
//
// 한 컴퓨트 dispatch로 vel을 초기화하고 P에 더한다.

float hash11(uint n) {
    n = (n ^ 61u) ^ (n >> 16u);
    n *= 9u;
    n = n ^ (n >> 4u);
    n *= 0x27d4eb2du;
    n = n ^ (n >> 15u);
    return float(n & 0x00ffffffu) / float(0x01000000u);
}

vec3 hash33(uint n) {
    return vec3(
        hash11(n * 3u + 0u),
        hash11(n * 3u + 1u),
        hash11(n * 3u + 2u)
    ) * 2.0 - 1.0;
}

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

    vec3 p = TDIn_P();
    vec3 v = hash33(id) * 0.5;     // [-0.5, 0.5]^3 무작위
    vec3 p_new = p + v * 0.1;

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

이 코드가 GPU에서 실제로 무엇을 하고 있는가. 첫째, TDIndex()로 현재 thread의 1D 인덱스를 받는다. 둘째, 표준 bounds-check 관용구로 workgroup 라운드업 잉여 thread를 즉시 차단한다. 셋째, TDIn_P()로 입력 P attribute를 읽는다. 넷째, 인덱스 기반의 결정론적 hash로 -0.5에서 0.5 범위의 vec3를 만들어 vel로 쓴다. 다섯째, P에 vel × 0.1을 더한 결과를 P SSBO의 같은 슬롯에 쓴다. 여섯째, vel SSBO의 같은 슬롯에 v 자체도 저장한다. 결과적으로 한 dispatch 안에서 두 SSBO에 동시에 쓴다 — 노드로는 세 노드(Attribute POP, Random POP, Math POP)에 해당하는 작업이다.

같은 결과를 GLSL Advanced POP으로도 쓸 수 있다. 차이는 출력 SSBO의 이름 prefix다.

// GLSL Advanced POP
// Shader Dispatch Mode    : single
// Number of Threads       : Per Input Point
// Point Output Attributes : P, vel
// Create Attributes       : vel (float3, class = point, default 0)
//
// 같은 결과를 Advanced POP으로. 출력 prefix가 oTDPoint_.

float hash11(uint n) {
    n = (n ^ 61u) ^ (n >> 16u);
    n *= 9u;
    n = n ^ (n >> 4u);
    n *= 0x27d4eb2du;
    n = n ^ (n >> 15u);
    return float(n & 0x00ffffffu) / float(0x01000000u);
}

vec3 hash33(uint n) {
    return vec3(
        hash11(n * 3u + 0u),
        hash11(n * 3u + 1u),
        hash11(n * 3u + 2u)
    ) * 2.0 - 1.0;
}

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

    vec3 p = TDInPoint_P();        // = TDInPoint_P(0, id, 0)
    vec3 v = hash33(id) * 0.5;
    vec3 p_new = p + v * 0.1;

    oTDPoint_P[id]   = p_new;
    oTDPoint_vel[id] = v;
}

이 코드는 GLSL POP 버전과 같은 일을 한다. 차이는 두 가지다. 첫째, 입력 reader가 TDInPoint_P()로 class prefix를 명시적으로 갖는다 — Advanced POP은 한 셰이더 안에서 세 class 모두 읽고 쓸 수 있으므로 prefix가 필수다. 둘째, 출력 SSBO가 oTDPoint_P, oTDPoint_vel로 prefix를 갖는다. Advanced POP에서 vel 같은 새 attribute를 만들 때는 Create Attributes 페이지의 시퀀스 블록에서 attr0class를 point로 명시해야 한다 — GLSL POP은 attrclass 한 파라미터로 셰이더 전체의 class가 고정되지만, Advanced POP은 attribute별로 class를 따로 지정한다.

두 구현 모두 한 dispatch 안에서 두 SSBO에 쓴다는 점에 주목한다. 노드로는 세 노드를 거치는 흐름이 GPU 측에서는 thread당 몇 줄의 산술로 끝난다. 노드 그래프는 가독성과 단계별 검증을, GLSL POP은 한 dispatch의 응축을 제공한다.

확인 질문 (Self-check)

  1. velocity attribute를 vertex class로 두면 시뮬레이션이 왜 다음 스텝에서 어색해지는가?
  2. point class attribute를 vertex class로 convert할 때 SSBO의 길이는 어떻게 바뀌는가? 그 변화는 메모리에서 실제로 일어나는가, 아니면 단순한 재해석인가?
  3. Houdini의 Attribute Promote SOP가 reduce 방식(average / min / max / first)을 지정한다는 사실은, 클래스 변환의 정보 손실에 관해 무엇을 함의하는가?
  4. GLSL POP에서 vel을 새로 만들었지만 Output Attributes에 적지 않으면 어떤 일이 일어나는가?
  5. 같은 attribute 이름(vel)이 point class와 vertex class에 동시에 존재할 수 있는가? Advanced POP의 출력 SSBO 이름 규칙은 그 가능성에 어떻게 대응하는가?

연결 고리

이 챕터의 한 줄 명제

데이터를 어느 attribute class에 둘 것인가는 GPU 프로그램의 첫 번째 설계 결정이며, Attribute POP의 메뉴 하나가 그 결정을 노드 그래프 위에 노출시킨다.