1.2 정의된 정점으로 삼각형 그리기
이전 튜토리얼에서는 명시적인 정점 데이터를 제공하지 않고 셰이더에서 정점 위치를 계산하여 삼각형을 그렸습니다. 이 접근 방식은 간단한 형상에는 작동하지만, 대부분의 실제 시나리오에서는 비실용적입니다. 이 튜토리얼에서는 명시적으로 정의된 정점 데이터를 사용하여 삼각형을 그려 보겠습니다. 이 방법은 복잡한 형상에 더 적합합니다.
플레이그라운드 실행 - 1_02_triangle_with_vertices이 튜토리얼에서는 다시 단일 삼각형을 그릴 것입니다. 하지만 이번에는 명시적으로 정점 데이터를 생성하여 그립니다.
@vertex
fn vs_main(
@location(0) inPos: vec3,
) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4(inPos, 1.0);
return out;
}
먼저, 셰이더 변경 사항을 살펴보겠습니다. 이전과 동일한 부분은 생략했습니다. `vs_main`의 입력은 `@builtin(vertex_index) in_vertex_index: u32`에서 `@location(0) inPos: vec3
함수 본문에서는 정점 위치가 셰이더로 전송될 것으로 예상되므로 더 이상 정점 위치를 파생할 필요가 없습니다. 여기서는 단순히 4개의 부동 소수점 벡터를 생성하고 해당 xyz 구성 요소를 입력 위치에, w 구성 요소를 1.0에 할당합니다.
나머지 셰이더는 동일합니다. 새 셰이더는 실제로 더 간단합니다.
const positionAttribDesc = {
shaderLocation: 0, // @location(0)
offset: 0,
format: 'float32x3'
};
이제 새 셰이더 코드를 적용하기 위한 파이프라인 변경 사항을 살펴보겠습니다. 먼저 위치 속성 설명자를 생성해야 합니다. 속성은 셰이더 함수 `@location(0) inPos: vec3
먼저, 속성의 위치 `shaderLocation`를 지정합니다. 이는 `@location(0)`에 해당합니다. 둘째, 이 속성의 첫 번째 요소를 찾기 위해 정점 데이터가 포함된 데이터 버퍼의 시작 부분에 대한 오프셋을 파이프라인에 알려줍니다. 이는 단일 버퍼에서 여러 속성을 섞을 수 있기 때문입니다. 마지막으로, `format` 필드는 형식을 정의하며 셰이더의 `vec3
const positionBufferLayoutDesc = {
attributes: [positionAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
};
다음 작업은 버퍼 레이아웃 설명자를 생성하는 것입니다. 이 단계는 GPU 파이프라인이 버퍼를 제출할 때 버퍼의 형식을 이해하는 데 중요한 역할을 합니다. 그래픽스 프로그래밍을 처음 접하는 사람들에게는 이러한 단계가 장황하게 느껴질 수 있으며, 속성 설명자와 레이아웃 설명자의 차이점과 GPU 버퍼를 설명하는 데 왜 필요한지 이해하기 어려울 수 있습니다.
정점 데이터를 GPU에 제출할 때, 일반적으로 수많은 정점의 데이터를 포함하는 큰 버퍼를 보냅니다. 첫 번째 챕터에서 소개했듯이, CPU 메모리에서 GPU 메모리로 소량의 데이터를 전송하는 것은 비효율적이므로, 대규모 배치로 데이터를 제출하는 것이 가장 좋습니다. 앞에서 언급했듯이, 정점 데이터는 정점 위치, 색상, 텍스처 좌표와 같이 여러 속성이 섞여 있을 수 있습니다. 또는 각 속성에 대해 별도의 전용 버퍼를 사용할 수도 있습니다. 그러나 정점 셰이더의 진입점에 도달하면 각 정점을 개별적으로 처리합니다. 이 단계에서는 전체 속성 버퍼의 가시성이 더 이상 없습니다. 각 셰이더 호출은 개별적으로 하나의 정점에서 작동하므로, 셰이더 프로그램은 GPU의 병렬 아키텍처의 이점을 누릴 수 있습니다.
CPU 측에서 단일 버퍼 청크를 제출하는 것에서 GPU 측에서 정점별 처리로 전환하기 위해, 우리는 입력 버퍼를 해부하여 각 개별 정점에 대한 정보를 추출해야 합니다. GPU 파이프라인은 레이아웃 설명자의 도움을 받아 이를 자동으로 수행할 수 있습니다. 속성 설명자와 레이아웃 설명자를 구별하자면, 속성 설명자는 위치 및 형식과 같은 속성 자체를 설명하는 반면, 레이아웃 설명자는 많은 정점에 대한 여러 속성 목록을 각 개별 정점에 대한 데이터로 분할하는 방법에 중점을 둡니다.
이 레이아웃 설명자 구조 내에는 속성 목록이 있습니다. 현재 예시에서는 위치만 다루므로 이 목록에는 위치 속성 설명자만 포함됩니다. 더 복잡한 시나리오에서는 이 목록에 더 많은 속성을 포함할 것입니다. 이어서 `arrayStride`를 정의합니다. 이 매개변수는 각 정점에 대해 버퍼 포인터를 진행할 때의 단계 크기를 나타냅니다. 예를 들어, 첫 번째 정점(정점 0)의 데이터는 버퍼 내 오프셋 0에 있습니다. 다음 정점(정점 1)의 데이터는 오프셋 0에 `arrayStride`를 더한 값, 즉 12번째 바이트(하나의 부동 소수점 4바이트 * 3)에서 시작하는 곳에 있습니다.
마지막으로 `step mode`를 지정합니다. `vertex`와 `instance` 두 가지 옵션이 있습니다. 둘 중 하나를 선택함으로써 우리는 GPU 파이프라인에게 이 버퍼의 포인터를 각 정점 또는 각 인스턴스에 대해 진행하도록 지시합니다. 인스턴싱 개념은 향후 챕터에서 자세히 다룰 것입니다. 그러나 대부분의 시나리오에서는 `vertex` 옵션으로 충분합니다.
const positions = new Float32Array([
1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0
]);
이제 실제 버퍼를 준비하겠습니다. 비교적 간단한 단계입니다. 여기서는 32비트 부동 소수점 배열을 생성하고 세 개의 정점의 좌표로 채웁니다. 이 배열에는 총 9개의 값이 포함됩니다.
이러한 좌표 값을 더 잘 이해하려면 이전에 소개했던 클립 공간 또는 화면 공간 좌표를 떠올려 보세요. 각 세 개의 값 세트는 3D 공간의 정점 위치를 나타냅니다. 첫 번째 정점 (1.0, -1.0, 0.0)은 클립 공간의 오른쪽 하단 모서리에 위치합니다. 두 번째 정점 (-1.0, -1.0, 0.0)은 왼쪽 하단 모서리에, 세 번째 정점 (0.0, 1.0, 0.0)은 클립 공간의 상단 중앙에 위치합니다. 이들은 시계 방향으로 정렬되어 있습니다.
이러한 좌표는 렌더링 표면의 가시 영역 전체에 걸쳐 삼각형을 형성하도록 의도적으로 선택되었습니다. z-좌표는 모든 정점에 대해 0.0으로 설정되어, 시야 방향에 수직인 동일한 평면에 배치됩니다. 이 배열은 화면의 절반을 덮는 삼각형을 만들며, 밑변은 하단 가장자리에 있고 꼭짓점은 상단 중앙에 위치합니다.
이 단계에서 우리가 생성한 데이터는 CPU 메모리에 있습니다. GPU 파이프라인에서 이를 활용하려면 이 데이터를 GPU 메모리로 전송해야 하며, 이는 GPU 버퍼를 생성하는 과정을 포함합니다.
const positionBufferDesc = {
size: positions.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
};
let positionBuffer = device.createBuffer(positionBufferDesc);
const writeArray =
new Float32Array(positionBuffer.getMappedRange());
writeArray.set(positions);
positionBuffer.unmap();
이 과정은 버퍼 설명자를 작성하는 것으로 시작합니다. 설명자의 첫 번째 필드는 버퍼의 크기를 지정하고, 그 뒤에 사용 플래그가 나옵니다. 이 버퍼를 정점 데이터를 공급하는 데 사용할 것이므로 `VERTEX` 플래그를 설정합니다. 마지막으로, 생성 시 이 버퍼를 매핑할지 여부를 결정합니다.
매핑은 CPU와 GPU 간의 데이터 전송에 앞서 수행되어야 하는 중요한 작업입니다. 이는 기본적으로 GPU 버퍼에 대해 CPU 측에 미러링된 버퍼를 생성합니다. 이 미러링된 버퍼는 CPU 데이터를 작성하는 스테이징 영역 역할을 합니다. 데이터 작성을 마치면 `unmap`을 호출하여 데이터를 GPU로 플러시합니다.
`mappedAtCreation` 플래그는 편리한 단축키를 제공합니다. 이 플래그를 설정하면 버퍼가 생성 시 자동으로 매핑되어 데이터 복사를 즉시 수행할 수 있습니다.
설명자 구조를 정의한 후, 다음 줄에서 이 설명자를 기반으로 버퍼를 생성합니다. 이 시점에서 버퍼는 이미 매핑되어 있으므로 데이터 작성을 진행할 수 있습니다.
우리의 접근 방식은 매핑된 GPU 버퍼에 직접 연결된 임시 32비트 부동 소수점 배열 `writeArray`를 생성하는 것입니다. 그런 다음 단순히 CPU 버퍼를 이 임시 배열로 복사합니다. 버퍼를 언매핑한 후에는 데이터가 GPU로 성공적으로 전송되어 셰이더에서 사용할 준비가 되었음을 확신할 수 있습니다.
const pipelineDesc = {
layout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayoutDesc]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-list',
frontFace: 'cw',
cullMode: 'back'
}
};
commandEncoder = device.createCommandEncoder();
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.draw(3, 1);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
코드의 나머지 부분은 이전 튜토리얼과 매우 유사하며, 몇 가지 주요 차이점만 있습니다. 눈에 띄는 한 가지 변경 사항은 파이프라인 설명자 정의에 나타납니다. 정점 단계 내에서 이제 `buffers` 필드에 버퍼 레이아웃 설명자를 제공합니다. 필요한 경우 이 필드는 여러 버퍼 설명자를 수용할 수 있다는 점에 유의해야 합니다.
또 다른 중요한 변경 사항은 파이프라인 설명자의 `primitive` 섹션에 있습니다. 우리는 시계 방향을 의미하는 `frontFace: cw`를 지정했는데, 이는 정점 버퍼의 정점 순서에 해당합니다. 이 설정은 GPU에 삼각형의 와인딩 순서를 알려주며, 이는 올바른 면 컬링에 중요합니다.
이 업데이트된 설명자를 사용하여 새 파이프라인을 생성한 후, 이 파이프라인으로 명령을 작성할 때 정점 버퍼를 설정해야 합니다. 우리는 `setVertexBuffer` 함수를 사용하여 이를 수행합니다. 첫 번째 매개변수는 파이프라인을 정의할 때 `buffers` 필드의 버퍼 레이아웃 인덱스에 해당하는 인덱스를 나타냅니다. 이 경우, GPU에 있는 `positionBuffer`가 정점 데이터의 소스로 사용되어야 한다고 지정합니다.
그리기 명령은 GPU에 세 개의 정점을 단일 삼각형으로 렌더링하도록 지시하는 이전 예제와 유사합니다. 그러나 이제 핵심적인 차이점은 이러한 정점들이 셰이더에서 생성되는 것이 아니라 명시적으로 정의된 버퍼에서 가져온다는 것입니다.
이 명령을 제출하면 화면에 단단한 삼각형이 렌더링되는 것을 볼 수 있습니다. 정점 데이터를 명시적으로 정의하는 이 접근 방식은 우리가 렌더링하는 형상에 대한 더 큰 유연성과 제어 기능을 제공하며, 향후 튜토리얼에서 더 복잡한 모양과 모델을 위한 길을 닦습니다.