버퍼 뷰
고친 사람 github-actions[bot]
버퍼 뷰는 큰 바이트 덩이에서 필요한 토막 하나만 골라 가리킵니다. 바이트를 떼어 내 복사하지 않고 어디서 시작해 얼마나 긴지만 적어 둡니다. 3차원 모델 파일에서는 한 덩이에 섞여 든 여러 데이터를 가르는 데 씁니다. 그래픽 카드를 다루는 프로그래밍에서는 그 위에서 도는 프로그램이 덩이를 어떤 형식으로 읽을지까지 붙여 건넵니다.
쉽고 빠른 이해
버퍼 뷰는 바이트 덩이 안의 한 토막에 붙이는 이름표입니다. 「0번 덩이의 72번째 바이트부터 6바이트」처럼 시작과 길이만 적습니다. 바이트를 떼어 내 복사하지는 않습니다.
이게 없으면 데이터 종류마다 파일이나 메모리를 따로 잡아야 합니다. 여러 번 읽고 여러 번 올리는 일이 그만큼 늘어납니다. 한 덩이에 몰아 담고 이름표만 여럿 붙이면 한 번에 읽어 올릴 수 있습니다.
어떻게 도나:
- 좌표와 번호 같은 여러 데이터를 한 덩이에 이어 붙입니다
- 토막마다 시작 바이트와 길이를 적은 버퍼 뷰를 하나씩 둡니다
- 읽는 쪽은 버퍼 뷰가 가리키는 범위만 봅니다. 그 바이트를 무엇으로 읽을지는 다른 설정이 알려 줍니다
대가는 복사하지 않고 원본을 나눠 쓴다는 데서 옵니다. 먼저, 한 버퍼 뷰로 바꾼 값이 같은 바이트를 보는 쪽 모두에 보입니다.
또 작은 버퍼 뷰 하나가 살아 있는 동안 원본 덩이 전체가 메모리에 남습니다. 그래서 오래 들고 있거나 따로 고칠 토막은 복사해서 씁니다.
상세
이 절은 버퍼 뷰가 무엇을 적는지부터 봅니다. 그다음 이 말을 가장 많이 쓰는 두 곳을 차례로 따라갑니다. 3차원 장면 파일인 glTF 에서는 삼각형 하나를 담은 파일을 예로 씁니다. 그래픽 카드를 직접 다루는 Vulkan 에서는 그 위에서 도는 프로그램이 덩이를 읽는 방식을 봅니다. 끝으로 프로그래밍 언어에 있는 같은 생각과 복사하지 않는 대가를 봅니다.
한 덩이 · 토막 여럿
두꺼운 제본 책 한 권에 보고서 여러 편을 묶어 두었다고 해 봅시다. 앞쪽 목차에는 「예산안은 40쪽부터 12쪽」처럼 편마다 시작 쪽과 쪽수만 적혀 있습니다. 예산안을 읽고 싶은 사람은 책을 뜯지 않고 목차를 보고 그 쪽을 폅니다.
버퍼는 바이트가 한 줄로 이어진 덩이입니다. 덩이는 안에 무엇이 들었는지 스스로 말하지 않습니다. 앞의 몇 바이트가 좌표이고 뒤의 몇 바이트가 번호라는 사실은 덩이 밖에 따로 적어 둬야 합니다.
버퍼 뷰가 그 기록입니다. 어느 버퍼인지, 몇 번째 바이트에서 시작하는지, 몇 바이트인지를 적습니다. 시작 위치를 덩이 맨 앞에서부터 센 거리를 오프셋이라고 부릅니다. 버퍼 뷰의 뼈대는 오프셋과 길이 둘입니다.
한 덩이에 몰아 담는 까닭은 읽고 옮기는 횟수에 있습니다. 데이터 종류마다 파일을 따로 두면 파일을 여러 번 열어야 합니다. 그래픽 카드로 올릴 때도 종류마다 따로 올려야 합니다. 한 덩이로 묶어 두면 한 번에 읽고 한 번에 올린 뒤, 버퍼 뷰로 토막만 가려 씁니다.
glTF 파일 속 버퍼 뷰
glTF(GL Transmission Format)는 3차원 장면을 프로그램 사이에 실어 나르는 파일 형식입니다.
장면의 구조는 JSON(JavaScript Object Notation, 자바스크립트 객체 표기법) 텍스트로 적습니다.
좌표 같은 숫자 뭉치는 이진 덩이 하나에 따로 담습니다. JSON 쪽의 bufferViews 배열이 그 덩이를
토막으로 가르는 목록입니다.
예로 삼각형 하나를 담아 봅니다. 꼭짓점이 셋이고 꼭짓점마다 위치와 법선을 들고 있습니다. 법선은 그 점에서 면이 어느 쪽을 향하는지를 나타내는 방향 값입니다. 조명을 계산할 때 이 방향을 씁니다.
위치는 4바이트 실수 셋이라 12바이트입니다. 법선도 12바이트입니다. 꼭짓점 하나 몫이 24바이트라 셋이면 72바이트입니다. 삼각형을 어느 꼭짓점 순서로 잇는지는 2바이트 번호 셋, 곧 6바이트로 적습니다. 덩이 전체는 78바이트입니다.
이 78바이트가 두 토막으로 갈리는 모양은 이렇습니다. 윗줄이 버퍼 뷰 둘이고 아랫줄이 그 안에 놓인 값입니다.
block-beta columns 7 v0["버퍼 뷰 0 · 0~71바이트"]:6 v1["버퍼 뷰 1 · 72~77"]:1 p0["위치 0"] n0["법선 0"] p1["위치 1"] n1["법선 1"] p2["위치 2"] n2["법선 2"] ix["순서 번호 3개"]
버퍼 뷰 0 은 위치와 법선이 꼭짓점 단위로 섞여 든 토막입니다. 버퍼 뷰 1 은 순서 번호만 담은
토막입니다. 이 두 토막을 JSON 으로 적으면 아래와 같습니다.
// 뒤는 설명을 위해 붙인 주석입니다. JSON 은 주석을 받지 않아서 실제 파일에는 적지 않습니다.
"bufferViews": [
{ "buffer": 0,
"byteOffset": 0, // 맨 앞부터
"byteLength": 72, // 꼭짓점 3 × 24
"byteStride": 24, // 다음 꼭짓점까지
"target": 34962 }, // 꼭짓점 값용
{ "buffer": 0,
"byteOffset": 72, // 앞 토막 바로 뒤
"byteLength": 6, // 번호 3 × 2
"target": 34963 } // 순서 번호용
]
buffer 는 몇 번째 덩이를 보는지 적습니다. byteOffset 과 byteLength 가 오프셋과 길이입니다.
byteStride 는 다음 소절에서 봅니다. target 은 이 토막을 그래픽 카드에 올릴 때 어떤 종류의
버퍼로 쓸지를 알리는 값입니다.
target 의 두 숫자는 다른 표준에서 빌려 온 번호입니다. OpenGL(Open Graphics Library)은
프로그램이 그래픽 카드에 그림을 그리게 할 때 쓰는 오래된 표준입니다. OpenGL 이 버퍼 종류마다
매겨 둔 번호를 glTF 가 가져다 씁니다.
그래픽스에서는 꼭짓점을 정점, 꼭짓점 순서 번호를 인덱스라고도 부릅니다. 34962 는 꼭짓점 값을 담는 정점 버퍼입니다. 34963 은 순서 번호를 담는 인덱스 버퍼입니다.
다섯 칸 가운데 꼭 적어야 하는 칸은 둘뿐입니다. 나머지는 안 적으면 정해진 뜻으로 읽힙니다.
| 칸 | 무엇을 적나 | 안 적으면 |
|---|---|---|
buffer |
몇 번째 덩이인가 | 꼭 적는다 |
byteLength |
토막이 몇 바이트인가 | 꼭 적는다 |
byteOffset |
덩이 맨 앞에서 몇 바이트 뒤에서 시작하나 | 0 |
byteStride |
한 꼭짓점에서 다음 꼭짓점까지 몇 바이트인가 | 값이 빈틈없이 붙어 있다고 읽는다 |
target |
그래픽 카드에서 어떤 버퍼로 쓰나 | 읽는 쪽이 쓰임새를 보고 정한다 |
한 토막을 여러 값이 나눠 읽을 때
버퍼 뷰는 바이트 범위만 압니다. 그 바이트를 4바이트 실수로 읽을지, 몇 개씩 묶을지는 적지 않습니다. glTF 에서 그 일은 액세서가 맡습니다. 액세서는 버퍼 뷰 하나를 가리킵니다. 그 안의 바이트를 어떤 자료형으로 몇 개 읽을지를 적습니다.
범위와 읽는 법을 가른 덕에 버퍼 뷰 하나를 액세서 여럿이 나눠 쓸 수 있습니다. 위 예의 버퍼 뷰 0 을 위치 액세서와 법선 액세서가 함께 읽습니다. 순서 번호 액세서는 버퍼 뷰 1 을 읽습니다.
flowchart TD
B["버퍼 · 78바이트"] --> V0["버퍼 뷰 0 · 섞어 담은 토막"]
B --> V1["버퍼 뷰 1 · 순서 번호 토막"]
V0 --> A1["위치 액세서 · 토막 안 0바이트부터"]
V0 --> A2["법선 액세서 · 토막 안 12바이트부터"]
V1 --> A3["순서 번호 액세서"]
두 액세서는 시작만 다릅니다. 위치 액세서는 토막 맨 앞부터, 법선 액세서는 12바이트 뒤부터 읽습니다. 둘 다 한 꼭짓점을 읽고 나면 24바이트를 건너뛰어 다음 꼭짓점으로 갑니다.
이 건너뛰는 거리가 byteStride 입니다. 흔히 스트라이드라고 부릅니다. 스트라이드는 버퍼
뷰에 적습니다. 그 뷰를 읽는 액세서 모두에 똑같이 걸립니다.
꼭짓점마다 붙은 위치·법선 같은 값을 꼭짓점 속성이라고 부릅니다. 꼭짓점 속성 둘 이상이 한 버퍼 뷰를 나눠 쓰면 스트라이드를 꼭 적어야 합니다.
스트라이드에는 규칙이 몇 가지 붙습니다. 4의 배수여야 하고 4에서 252 사이여야 합니다. 꼭짓점
값이 아닌 데이터, 예를 들어 순서 번호를 담은 토막에는 스트라이드를 적지 않습니다. 위 JSON 에서
버퍼 뷰 1 에 byteStride 가 없는 까닭입니다.
액세서가 토막 안에서 시작하는 위치에도 규칙이 있습니다. 읽는 값 한 개 크기의 배수여야 합니다. 4바이트 실수를 읽는 법선 액세서가 12바이트에서 시작하는 것은 12 가 4 의 배수라서 됩니다. 이렇게 맞춰 두면 그래픽 카드가 값을 한 번에 꺼낼 수 있습니다.
그림도 토막으로 담는다
glTF 는 장면 전체를 이진 파일 하나로 묶은 판도 둡니다. 이때는 물체 표면에 입힐 그림, 곧 텍스처 이미지의 바이트도 같은 덩이 안에 들어갑니다.
이미지는 파일 주소 대신 버퍼 뷰 번호를 가리킵니다. 바이트만으로는 PNG(Portable Network Graphics)인지 JPEG(Joint Photographic Experts Group)인지 알 수 없어서 이미지 종류를 함께 적습니다. 좌표와 그림이 한 덩이에 들어도 버퍼 뷰가 둘을 가릅니다.
Vulkan 의 버퍼 뷰
Vulkan은 그래픽 카드를 직접 다루는 프로그래밍 API(Application Programming Interface, 응용 프로그래밍 인터페이스)입니다. 여기서도 버퍼는 그래픽 카드 쪽 메모리에 잡은 바이트 덩이입니다. Vulkan 에서 버퍼 뷰라고 하면 그 덩이의 이어진 한 범위에 형식을 붙인 것을 가리킵니다.
형식은 바이트를 몇 개씩 묶어 어떤 값으로 읽을지를 정합니다. 「8비트 정수 넷을 한 칸으로 보고 0~1 사이 실수로 바꿔 읽는다」 같은 것이 형식 하나입니다. glTF 에서 액세서가 하던 일의 일부를 버퍼 뷰가 직접 지는 셈입니다.
이 형식을 쓰는 쪽은 셰이더입니다. 셰이더는 그래픽 카드에서 도는 작은 프로그램입니다. 화면에 그릴 점의 위치나 색을 셰이더가 계산합니다. 계산에 쓸 값은 버퍼에서 읽어 옵니다.
형식을 붙인 버퍼 뷰가 있으면 셰이더는 버퍼를 이미지처럼 읽습니다. 이미지처럼 읽는다는 것은 몇 번째 칸인지 번호만 대면 형식대로 풀린 값을 받는다는 뜻입니다. 셰이더가 바이트를 직접 끊어 값으로 바꾸지 않아도 됩니다.
이미지의 한 칸을 텍셀이라고 부릅니다. 그래서 이렇게 읽히는 버퍼를 텍셀 버퍼라고 합니다.
텍셀 버퍼는 두 종류입니다. 셰이더가 읽기만 하는 것과 읽고 쓰기도 하는 것입니다. 버퍼를 만들 때부터 텍셀 버퍼로 쓰겠다는 것과 어느 쪽으로 쓸지를 밝혀 둡니다. 그래야 그 위에 버퍼 뷰를 만들 수 있습니다.
두 곳의 버퍼 뷰를 나란히 놓으면 이렇게 갈립니다.
| glTF | Vulkan | |
|---|---|---|
| 어디에 있나 | 파일 속 JSON 목록 | 그래픽 카드 쪽 객체 |
| 무엇을 적나 | 덩이 · 오프셋 · 길이 · 스트라이드 | 덩이 · 오프셋 · 길이 · 형식 |
| 읽는 법은 누가 정하나 | 버퍼 뷰를 가리키는 액세서 | 버퍼 뷰 자신 |
| 누가 읽나 | 파일을 불러오는 프로그램 | 셰이더 |
두 곳 모두 뼈대는 같습니다. 덩이 하나를 두고 복사 없이 그 안의 범위를 가리킵니다.
프로그래밍 언어 속 같은 생각
그래픽스 밖에서도 바이트 덩이의 한 토막을 복사 없이 가리키는 물건은 흔합니다. 자바스크립트의 ArrayBuffer가 바이트 덩이입니다. 그 위에 얹는 타입 배열이 버퍼 뷰 노릇을 합니다.
아래 코드는 8바이트 덩이를 만듭니다. 그 위에 4번째 바이트부터 2바이트만 가리키는 뷰 part 를
얹습니다.
all 은 같은 덩이 전체를 보는 뷰입니다.
const buf = new ArrayBuffer(8);
const all = new Uint8Array(buf);
const part = new Uint8Array(buf, 4, 2);
part[0] = 7;
all[4]; // 7
part.length; // 2
part 의 0번 칸에 쓴 값이 all 의 4번 칸에서 읽힙니다. 두 뷰가 한 덩이의 같은 바이트를 보고
있어서입니다. 파이썬의 memoryview와 Go 의 슬라이스도 같은 방식으로 원본을 나눠 씁니다.
복사하지 않는 대가
복사를 안 하니 같은 바이트를 여러 뷰가 함께 봅니다. 한쪽에서 값을 고치면 다른 쪽에서도 바뀐 값이 보입니다. 따로 고칠 생각이었다면 이것이 버그가 됩니다.
원본의 수명도 뷰에 묶입니다. 큰 덩이에서 작은 뷰 하나만 오래 들고 있으면, 그 뷰 때문에 덩이 전체가 메모리에서 풀려나지 않습니다. 반대로 원본을 먼저 풀어 버리면 뷰는 빈 메모리를 가리킵니다.
범위를 잘못 적는 실수도 있습니다. 오프셋에 길이를 더한 값이 덩이 크기를 넘으면 뷰가 덩이 밖을 가리킵니다. glTF 파일이라면 그 파일은 잘못된 파일이 됩니다.
정리하면, 한 덩이에 여러 데이터를 담아 한꺼번에 옮길 때 버퍼 뷰를 씁니다. 토막 하나만 떼어 오래 들고 있거나 따로 고쳐야 한다면 그 토막을 복사해 새 덩이로 만듭니다.
관련 항목
버퍼 뷰가 잘라 내는 원본 덩이
버퍼 · 버퍼 객체 · 바이트 · 바이트 배열 · 비디오 메모리 · GLB
버퍼 뷰 위에 읽는 법을 덧붙이는 설정
액세서 · 스트라이드 · 오프셋 · 정점 속성 · 정점 레이아웃 · 컴포넌트 타입 · 메모리 정렬
버퍼 뷰로 가려 담는 데이터
정점 버퍼 · 인덱스 버퍼 · 법선 · 텍스처 · 이미지 · 애니메이션 · 스키닝
버퍼 뷰를 정의하는 파일 포맷과 그래픽 API
glTF · Vulkan · OpenGL · Direct3D 12 · WebGPU · API
Vulkan 에서 버퍼 뷰와 짝을 이루는 자원
텍셀 버퍼 · 유니폼 텍셀 버퍼 · 스토리지 텍셀 버퍼 · 텍셀 · 이미지 뷰 · 디스크립터 · 포맷 · 셰이더
같은 방식으로 원본을 나눠 쓰는 언어 도구
ArrayBuffer · 타입 배열 · DataView · memoryview · 슬라이스 · 제로 카피
버퍼 뷰를 잘못 잡으면 나는 오류
범위 초과 읽기 · 정렬 오류 · 댕글링 포인터 · 메모리 누수
버퍼 뷰가 속하는 상위 분류
다른 이름: buffer view · bufferView · VkBufferView