셰이더 바인딩 테이블
고친 사람 github-actions[bot]
셰이더 바인딩 테이블은 화면의 점마다 광선을 쏘아 3D 장면을 그릴 때 쓰는 표입니다. 광선이 물체에 맞으면 어느 셰이더를 돌릴지 알려 줍니다. 물체와 광선 종류마다 돌릴 셰이더를 한 칸씩 적어 둡니다. 그래픽 카드는 광선이 맞을 때마다 이 표에서 칸 하나를 찾아 그 셰이더를 부릅니다. 표는 앱이 직접 채워 그래픽 카드 메모리에 올려 둡니다.
쉽고 빠른 이해
3D 장면을 화면 점마다 광선을 쏘아 그리는 방식(레이 트레이싱)에서, 광선이 맞은 물체에 맞는 프로그램을 골라 주는 연결표입니다. 거울 공에 맞으면 반사를 계산하는 프로그램을, 나무 바닥에 맞으면 나뭇결을 칠하는 프로그램을 고릅니다.
광선이 어디에 맞을지는 쏴 봐야 압니다. 화면 한 장에 이 고르기가 수백만 번 일어나서 그때마다 앱에 물어볼 수 없습니다. 그래서 미리 표를 만들어 그래픽 카드가 스스로 찾게 합니다.
어떻게 도나:
- 프로그램들을 묶어 준비한 뒤 드라이버에게서 프로그램마다 짧은 표식을 받습니다
- 표식과 물체별 값을 칸마다 적어 그래픽 카드 메모리에 올립니다
- 광선이 맞으면 물체 번호와 광선 종류로 칸 번호를 셈해 그 칸의 프로그램을 돌립니다
대가가 있습니다. 칸 수가 물체 수와 광선 종류 수의 곱으로 늘어납니다. 칸 크기와 번호는 앱이 직접 맞춰야 합니다. 틀리면 엉뚱한 프로그램이 돕니다.
광선을 쏘고 맞은 물체마다 그래픽 카드가 프로그램을 골라 돌리게 할 때 이 표를 씁니다. 프로그램 안에서 광선을 쏘고 결과를 직접 분기하는 방식(레이 쿼리)에서는 표를 만들지 않습니다.
상세
이 절은 셰이더 바인딩 테이블이 왜 필요한지부터 봅니다. 그다음 칸 하나에 무엇이 들어가는지, 표가 네 구역으로 어떻게 나뉘는지, 그래픽 카드가 칸 번호를 어떻게 셈하는지를 봅니다.
예로는 거울 공 · 나무 바닥 · 나무 상자 셋이 놓인 장면을 줄곧 씁니다. 광선은 색을 보려는 것과 그림자를 보려는 것 두 종류를 쏩니다.
광선이 맞은 뒤에 고를 셰이더
레이 트레이싱은 눈에서 화면의 점마다 직선을 쏘아 그 점의 색을 정하는 그리기 방식입니다. 이 직선을 광선이라고 부릅니다. 광선이 처음 맞은 물체가 그 점에 보이는 물체입니다.
맞은 물체의 색은 셰이더가 계산합니다. 셰이더는 GPU(Graphics Processing Unit, 그래픽 카드에 든 처리 장치)에서 도는 작은 프로그램입니다. 거울 공에는 비친 모습을 계산하는 셰이더가 필요합니다. 나무 바닥에는 나뭇결 무늬를 칠하는 셰이더가 필요합니다.
어느 셰이더를 돌릴지는 광선을 쏴 봐야 압니다. 광선이 어디에 맞을지 미리 모르기 때문입니다. 화면이 가로 1920 점, 세로 1080 점이면 광선이 약 207만 개입니다. 광선마다 이 고르기를 한 번 이상 합니다.
그때마다 CPU(Central Processing Unit, 중앙 처리 장치)에서 도는 앱에 물으려면 GPU 가 답을 기다려야 합니다. 광선 수백만 개가 그 기다림에 묶입니다. 그래서 앱은 「이 물체에 맞으면 이 셰이더」를 미리 표로 적어 GPU 메모리에 올려 둡니다. 이 표가 셰이더 바인딩 테이블(shader binding table, SBT)입니다.
GPU 는 광선이 맞을 때마다 이 표를 스스로 찾아봅니다. 백엔드 개발자에게 익숙한 모양으로는 디스패치 테이블입니다. 함수 포인터를 배열에 담아 두고 번호로 꺼내 부르는 표와 같은 모양입니다.
표가 가리키는 셰이더들
광선 하나를 쏘아 결과를 돌려받기까지 셰이더 다섯 종류가 끼어듭니다. 종류마다 하는 일이 다릅니다. 표의 칸은 이 다섯을 모두 가리킵니다.
장면의 물체는 보통 작은 삼각형을 이어 붙여 만듭니다. 광선이 삼각형과 겹치는지는 GPU 가 스스로 셈합니다. 삼각형이 아닌 모양만 셰이더가 따로 봅니다. 다섯 셰이더가 언제 돌고 무슨 일을 하는지는 아래와 같습니다.
| 셰이더 | 언제 도나 | 하는 일 |
|---|---|---|
| 광선 생성 셰이더 | 화면의 점마다 한 번 | 점을 지나는 광선을 쏘고 돌아온 색을 그 점에 쓴다 |
| 최근접 적중 셰이더 | 맞은 물체 가운데 가장 가까운 것이 정해진 뒤 | 거울 공은 반사 광선을 하나 더 쏘고, 나무 바닥은 나뭇결 색을 적는다 |
| 임의 적중 셰이더 | 광선이 물체와 겹칠 때마다 | 나뭇잎 그림의 투명한 부분에 맞았으면 이 겹침을 무시하게 한다 |
| 교차 셰이더 | 삼각형이 아닌 모양과 겹치는지 볼 때 | 수식으로 적은 공과 광선이 만나는지 계산한다 |
| 미스 셰이더 | 광선이 아무것에도 안 맞았을 때 | 하늘색을 적는다 |
다섯 가운데 최근접 적중 · 임의 적중 · 교차 셋은 물체마다 다르게 고릅니다. 그래서 셋을 한 묶음으로 짝지어 둡니다. 이 묶음이 적중 그룹(hit group)입니다.
다섯 말고 표가 가리키는 셰이더가 하나 더 있습니다. 호출 가능 셰이더(callable shader)는 다른 셰이더가 함수처럼 불러 쓰는 셰이더입니다. 여러 셰이더가 같이 쓰는 계산을 떼어 둘 때 씁니다.
표의 칸이 가리키는 단위는 셰이더 그룹입니다. 적중 그룹이 셰이더 그룹 하나입니다. 광선 생성 · 미스 · 호출 가능 셰이더는 저마다 혼자서 셰이더 그룹 하나가 됩니다.
칸 하나에 담기는 값
칸 하나를 뜯어 보면 두 가지가 들어 있습니다. 어느 셰이더 그룹을 돌릴지 가리키는 표식과, 그 셰이더가 읽을 값입니다. 칸 하나를 셰이더 레코드(shader record)라고 부릅니다.
표식을 얻으려면 먼저 셰이더들을 한 덩어리로 묶어야 합니다. 광선을 쏘는 데 쓸 셰이더를 실행 전에 묶어 GPU 가 바로 돌리는 명령어로 옮겨 둔 덩어리가 레이 트레이싱 파이프라인입니다. 옮기는 일이 느려서 실행 전에 한 번만 해 둡니다.
드라이버는 앱의 명령을 GPU 가 알아듣는 꼴로 바꿔 주는 소프트웨어입니다. 앱은 GPU 를 직접 다루지 않고 언제나 드라이버를 거쳐 일을 시킵니다.
파이프라인을 만들면 드라이버가 셰이더 그룹마다 짧은 바이트 덩어리를 하나씩 내줍니다. 이 바이트 덩어리가 칸에 적는 표식입니다. 이 표식이 셰이더 그룹 핸들입니다.
앱은 핸들 속을 읽지 않습니다. 옮겨 둔 셰이더가 GPU 안 어디에 어떤 꼴로 놓였는지는 드라이버만 압니다. 앱은 받은 바이트를 손대지 않고 칸에 옮겨 적기만 합니다.
핸들 뒤에는 그 물체만 쓸 값을 이어 적을 수 있습니다. 이 글에서는 이 값을 「덧붙인 값」이라고 부릅니다. 같은 셰이더를 여러 물체가 나눠 쓰면서 물체마다 다른 데이터를 읽게 해 줍니다.
나무 바닥과 나무 상자가 같은 나뭇결 셰이더를 쓴다고 해 봅시다. 셰이더는 하나로 두고 칸마다 다른 텍스처를 가리키게 합니다. 텍스처는 물체 표면에 입히는 그림입니다. 바닥 칸에는 바닥 무늬를, 상자 칸에는 상자 무늬를 가리키는 값을 덧붙입니다.
칸 간격과 정렬
GPU 는 칸을 번호로 찾습니다. 그러려면 한 구역 안의 칸들이 모두 같은 간격으로 놓여야 합니다. 다음 칸까지 건너뛰는 바이트 수를 칸 간격, 영어로 스트라이드(stride)라고 부릅니다.
칸마다 덧붙인 값의 길이가 다를 수 있습니다. 그래서 칸 간격은 그 구역에서 가장 긴 칸에 맞춥니다. 짧은 칸은 끝에 빈 바이트를 채워 간격을 맞춥니다.
칸 간격과 구역의 시작 주소는 정해진 수의 배수여야 합니다. 이렇게 맞추는 일을 정렬이라고 부릅니다. GPU 가 메모리를 정해진 단위로 묶어 읽기 때문에 필요합니다. 그 단위는 드라이버에게 물어 받습니다.
칸 하나를 바이트 순서대로 펼치면 이렇게 놓입니다. 상자 칸의 예입니다.
block-beta columns 4 H["셰이더 그룹 핸들 · 드라이버가 준 바이트"]:2 V["덧붙인 값 · 상자 무늬를 가리킴"]:1 P["빈 바이트 · 간격 맞춤"]:1
네 구역
표는 GPU 메모리의 한 덩어리지만 안은 네 구역으로 나뉩니다. 광선 생성 · 미스 · 적중 · 호출 가능 구역입니다. GPU 는 찾는 셰이더의 종류를 보고 구역을 고릅니다. 그다음 그 구역 안에서 칸을 찾습니다.
구역마다 GPU 가 언제 찾고 칸 번호를 무엇으로 정하는지는 다음과 같습니다.
| 구역 | GPU 가 언제 찾나 | 칸 번호를 정하는 값 |
|---|---|---|
| 광선 생성 | 광선을 쏘라는 명령이 시작될 때 | 칸이 하나뿐이다 |
| 미스 | 광선이 아무것에도 안 맞았을 때 | 광선을 쏠 때 적은 미스 번호 |
| 적중 | 광선이 물체에 맞았을 때 | 물체 · 조각 · 광선 종류 (다음 소절) |
| 호출 가능 | 셰이더가 호출 가능 셰이더를 부를 때 | 부를 때 적은 번호 |
미스 구역에 칸이 여럿인 까닭은 광선 종류마다 빗나갔을 때 할 일이 달라서입니다. 색 광선이 빗나가면 하늘색을 적습니다. 그림자 광선이 빗나가면 「전등까지 막힌 것이 없다」를 적습니다.
앱은 광선을 쏘라는 명령에 구역마다 세 값을 실어 보냅니다. 구역의 시작 주소, 칸 간격, 구역 전체의 크기입니다. 장면 예로 네 구역을 펼치면 이렇게 놓입니다.
block-beta columns 2 RG["광선 생성 구역 · 칸 1개"]:2 M0["미스 0번 칸 · 색 광선"] M1["미스 1번 칸 · 그림자 광선"] H0["적중 0번 칸 · 거울 공 · 색"] H1["적중 1번 칸 · 거울 공 · 그림자"] H2["적중 2번 칸 · 나무 바닥 · 색"] H3["적중 3번 칸 · 나무 바닥 · 그림자"] H4["적중 4번 칸 · 나무 상자 · 색"] H5["적중 5번 칸 · 나무 상자 · 그림자"] CL["호출 가능 구역"]:2
적중 구역은 물체마다 광선 종류 수만큼 칸을 이어 붙였습니다. 물체 셋에 광선 종류 둘이라 여섯 칸입니다.
적중 구역의 칸 번호 셈
광선이 물체에 맞으면 GPU 는 세 가지 번호를 모아 적중 구역의 칸 번호를 냅니다. 이 셈이 표의 핵심입니다. 그 셋을 보려면 장면이 GPU 에 어떻게 올라가는지부터 알아야 합니다.
장면의 물체들은 가속 구조에 올려 둡니다. 가속 구조는 장면을 큰 덩어리에서 작은 덩어리로 나눠 물체를 담아 둔 구조입니다. 광선이 지나지 않는 덩어리를 통째로 건너뛰어 맞은 물체를 빨리 찾습니다.
물체 하나를 가속 구조에 올린 단위를 인스턴스라고 부릅니다. 같은 모양의 상자를 열 개 놓으면 모양 하나에 인스턴스 열 개입니다.
한 물체가 여러 조각으로 이뤄지기도 합니다. 자동차라면 차체와 유리창이 다른 조각이고 서로 다른 셰이더를 씁니다. 이 조각을 지오메트리(geometry)라고 부릅니다. 인스턴스 안에서 조각마다 0 부터 번호가 붙습니다.
이제 세 번호를 한 표로 모읍니다.
| 번호 | 어디서 오나 | 상자를 그림자 광선이 맞힌 경우 |
|---|---|---|
| 인스턴스의 시작 칸 | 앱이 인스턴스마다 적어 둔다 | 4 |
| 조각 번호 | 인스턴스 안에서 몇 번째 조각인가 | 0 · 상자는 조각이 하나다 |
| 광선 종류 번호 | 광선을 쏠 때 적는다 | 1 · 그림자 광선 |
조각 번호에는 곱할 수가 하나 붙습니다. 이 수도 광선을 쏠 때 적습니다. 흔히 광선 종류 수를 적습니다. 조각마다 광선 종류 수만큼 칸이 이어 붙어 있어서입니다.
셈을 코드로 옮기면 이렇습니다. 줄마다 오른쪽 주석이 그 값입니다.
int rayTypes = 2; // 색 · 그림자
int instStart = 4; // 상자의 시작 칸
int geometry = 0; // 상자는 조각 하나
int rayType = 1; // 그림자 광선
int slot = instStart
+ geometry * rayTypes
+ rayType; // 5
나온 5 는 앞 그림의 「적중 5번 칸 · 나무 상자 · 그림자」입니다. GPU 는 적중 구역의 시작 주소에서 칸 간격의 다섯 배만큼 건너뛰어 그 칸을 읽습니다. 나무 바닥을 색 광선이 맞혔다면 시작 칸 2 에 0 과 0 을 더해 2번 칸입니다.
인스턴스의 시작 칸은 앱이 정합니다. 그래서 여러 인스턴스가 칸을 나눠 쓸 수 있습니다. 같은 상자 열 개가 모두 시작 칸 4 를 적으면 칸 두 개로 열 개를 다 그립니다. 상자마다 무늬가 달라야 할 때만 칸을 따로 둡니다.
번호를 잘못 적으면 다른 물체의 칸을 읽습니다. 화면에는 엉뚱한 무늬가 칠해지거나 결과를 믿을 수 없게 됩니다.
표를 채워 올리는 순서
앱이 표를 만들어 쓰기까지는 세 단계를 거칩니다. 앞 단계가 끝나야 다음 단계에 쓸 값이 나옵니다.
앱은 먼저 레이 트레이싱 파이프라인을 만듭니다. 그다음 드라이버에게서 셰이더 그룹마다 핸들을 받아 옵니다. 핸들은 파이프라인이 있어야 나오므로 이 순서는 바뀌지 않습니다.
이어서 칸마다 핸들과 덧붙인 값을 적어 버퍼에 담습니다. 버퍼는 GPU 메모리에 잡은 데이터 덩어리입니다. 표가 GPU 메모리에 있어야 GPU 가 앱에 묻지 않고 바로 읽습니다.
마지막으로 광선을 쏘라는 명령에 네 구역의 위치를 실어 보냅니다. 그 뒤로는 광선이 맞을 때마다 GPU 가 스스로 칸을 찾아 셰이더를 돌립니다.
표를 파이프라인 밖에 두는 까닭
파이프라인은 한 번 만들면 안 바뀝니다. 셰이더를 GPU 명령어로 옮기는 일이 느려서 실행 중에 다시 만들면 그동안 화면이 끊길 수 있습니다. 표는 파이프라인 밖의 평범한 버퍼라 언제든 고쳐 쓸 수 있습니다.
물체와 셰이더의 짝을 바꿀 때도 파이프라인은 건드리지 않습니다. 나무 상자를 금속처럼 보이게 하려면 상자 칸의 핸들을 금속 셰이더 그룹의 핸들로 바꿔 적으면 됩니다. 금속 셰이더 그룹이 파이프라인에 이미 들어 있다면 그걸로 끝납니다.
물체가 늘 때도 같습니다. 칸을 이어 붙입니다. 새 인스턴스에는 시작 칸을 적습니다. 백엔드로 치면 애플리케이션은 다시 빌드하지 않고 Nginx 의 location 설정만 바꿔 요청을 다른 서버로 보내는 셈입니다.
래스터화로 그릴 때는 사정이 다릅니다. 래스터화는 물체를 하나씩 화면에 눌러 찍어 그 물체가 덮는 점을 칠하는 방식입니다. 물체마다 그리기 명령을 따로 내므로 앱이 명령마다 셰이더를 골라 줍니다.
광선은 한 번의 명령 안에서 어느 물체에 맞을지 모릅니다. 명령 단위로는 셰이더를 골라 줄 수가 없습니다. 고르기는 GPU 가 읽는 표에 맡깁니다.
찾는 비용과 표의 크기
칸 번호는 곱셈 한 번과 덧셈 두 번으로 나옵니다. 주소는 그 번호에 칸 간격을 곱해 구역의 시작 주소에 더하면 나옵니다. 표가 아무리 길어도 이 비용은 안 늘어납니다. 복잡도로 적으면 O(1) 입니다.
대가는 공간에서 치릅니다. 먼저 칸을 따로 갖는 조각이 몇 개인지 셉니다. 칸을 나눠 쓰는 조각은 한 번만 셉니다. 거기에 광선 종류 수를 곱하면 적중 구역의 칸 수입니다.
표의 바이트 수는 그 칸 수에 칸 간격을 곱한 값입니다. 곱이라서 어느 쪽이 늘어도 빨리 커집니다.
서로 다른 물체가 1000 개이고 물체마다 조각이 셋이면 조각이 3000 개입니다. 광선 종류가 둘이면 6000 칸입니다. 광선 종류를 하나 더 들이면 9000 칸이 됩니다.
표를 줄이는 손쉬운 길은 칸 번호 셈에서 본 나눠 쓰기입니다. 무늬가 같은 물체들이 같은 시작 칸을 적으면 칸 묶음 하나로 여럿을 그립니다.
Direct3D 12 와 Vulkan 이 부르는 이름
앱이 GPU 에 일을 시킬 때 쓰는 함수 묶음을 그래픽스 API(Application Programming Interface, 응용 프로그램 인터페이스)라고 합니다. 윈도우의 Direct3D 12와 여러 운영체제에서 쓰는 Vulkan이 대표적입니다. 둘 다 레이 트레이싱 파이프라인을 지원합니다.
Direct3D 12 에서는 레이 트레이싱 기능을 묶어 DXR(DirectX Raytracing)이라고 따로 부릅니다. 두 API 모두 이 표를 셰이더 바인딩 테이블, 칸을 셰이더 레코드라고 부릅니다. 그 밖의 이름은 조금씩 다릅니다.
| Direct3D 12 | Vulkan | |
|---|---|---|
| 칸에 적는 표식 | 셰이더 식별자 (shader identifier) | 셰이더 그룹 핸들 (shader group handle) |
| 표식을 받는 호출 | GetShaderIdentifier |
vkGetRayTracingShaderGroupHandlesKHR |
| 덧붙인 값 | 로컬 루트 인자 (local root arguments) | 셰이더 레코드 버퍼 · 셰이더에서 shaderRecordEXT 로 읽는다 |
| 인스턴스의 시작 칸 | InstanceContributionToHitGroupIndex |
instanceShaderBindingTableRecordOffset |
| 광선을 쏘라는 명령 | DispatchRays |
vkCmdTraceRaysKHR |
칸 번호를 셈하는 방식과 표를 네 구역으로 나누는 방식은 두 API 가 같습니다.
쓸 때와 안 쓸 때
셰이더 바인딩 테이블은 레이 트레이싱 파이프라인으로 광선을 쏠 때만 씁니다. 광선을 쏘는 길은 하나가 더 있습니다.
레이 쿼리는 셰이더 안에서 광선을 쏘고 맞은 결과를 바로 돌려받는 방식입니다. 맞은 물체에 따라 할 일을 그 셰이더가 직접 분기로 씁니다. 그래서 표를 만들지 않습니다.
물체마다 다른 셰이더를 많이 둘수록 표가 하는 일이 커집니다. 셰이더 하나가 모든 물체를 처리한다면 고를 셰이더가 없어 표가 하는 일이 줄어듭니다.
표는 대개 게임 엔진이 안쪽에서 짓고 고칩니다. 엔진을 쓰는 개발자가 칸을 손으로 채울 일은 드뭅니다. API 를 직접 다루는 엔진 개발자나 렌더러 개발자가 이 표를 짭니다.
관련 항목
셰이더 바인딩 테이블이 속하는 상위 분류
레이 트레이싱 파이프라인 · 레이 트레이싱 · 그래픽스 · 그래픽스 API · GPU 프로그래밍
셰이더 바인딩 테이블을 이루는 구성 요소
셰이더 레코드 · 셰이더 그룹 핸들 · 셰이더 식별자 · 로컬 루트 인자 · 로컬 루트 시그니처 · 스트라이드 · 메모리 정렬
칸이 가리키는 셰이더 그룹
셰이더 그룹 · 적중 그룹 · 광선 생성 셰이더 · 최근접 적중 셰이더 · 임의 적중 셰이더 · 교차 셰이더 · 미스 셰이더 · 호출 가능 셰이더
칸 번호를 정하는 입력
가속 구조 · 인스턴스 · 지오메트리 · 하위 가속 구조 · 상위 가속 구조 · 광선 페이로드
표를 담아 GPU 에 올리는 자원
버퍼 · GPU 메모리 · 버퍼 디바이스 주소 · 디바이스 드라이버 · 커맨드 버퍼
셰이더 바인딩 테이블을 두는 그래픽스 API
Direct3D 12 · DXR · Vulkan · DispatchRays · vkCmdTraceRaysKHR
셰이더 바인딩 테이블을 대신하는 수단
레이 쿼리 · 인라인 레이 트레이싱 · 교차 함수 테이블 · 가시 함수 테이블
번호로 함수를 골라 부르는 같은 모양의 표
디스패치 테이블 · 가상 함수 테이블 · 함수 포인터 · 점프 테이블
셰이더를 그리기 명령마다 고르는 방식
그래픽스 파이프라인 · 래스터화 · 드로우 콜 · 파이프라인 상태 객체
다른 이름: shader binding table · SBT · 셰이더 바인딩 표