1.1 삼각형 그리기
이전 튜토리얼에서는 아무것도 그리지 않았기 때문에 조금 지루했습니다. 이번 튜토리얼에서는 삼각형 하나를 그려보겠습니다.
플레이그라운드 실행 - 1_01_triangle3D 렌더링 영역에서 삼각형은 가장 기본적인 요소를 그리는 역할을 합니다. 삼각형은 3D 세계의 픽셀과 같아서 첫 번째 튜토리얼의 이상적인 시작점입니다. 여기서는 단일 삼각형을 그리는 방법을 배웁니다. 삼각형 내부의 픽셀 색상을 정의하는 간단한 셰이더를 만들고, 이 삼각형을 셰이더를 사용하여 화면에 렌더링하는 그래픽 파이프라인을 구축하는 방법을 이해하는 과정을 거칩니다. 전통적인 프로그래밍의 "Hello World" 프로그램처럼, 삼각형 그리기는 모든 그래픽스 API에 대한 동등한 소개 역할을 합니다.
이전 예제에서는 셰이더를 생성하지 않았습니다. 앞서 언급했듯이, 셰이더 프로그램은 GPU에서 실행되는 프로그램입니다. 일반적으로 셰이더 프로그램에는 세 가지 주요 유형이 있습니다: 정점 셰이더, 프래그먼트 셰이더, 그리고 컴퓨트 셰이더. 컴퓨트 셰이더는 일반적인 계산에 사용되는 반면, 정점 및 프래그먼트 셰이더는 렌더링과 특별히 관련이 있습니다. 정점 셰이더는 지오메트리의 각 정점을 처리하여 화면에서의 최종 위치를 결정합니다. 프래그먼트 셰이더는 이 정점들이 정의하는 도형 내의 각 픽셀 색상을 결정합니다. 이 셰이더들은 함께 작동하여 점이나 삼각형과 같은 지오메트리 프리미티브를 화면에 보이는 픽셀로 변환합니다.
<script id="shader" type="wgsl">
...
</script>이제 셰이더의 역할을 이해했으니, 프로젝트에 셰이더를 추가해 봅시다. 먼저, HTML에 셰이더 코드를 담을 또 다른 스크립트 태그를 생성합니다. 이번에는 wgsl(WebGPU 셰이더 언어)로 타입을 설정합니다. 타입 외에도 shader라는 id를 부여해야 하는데, 나중에 이 내용을 읽어야 하기 때문입니다. 셰이더 코드를 스크립트 태그 안에 넣는 것이 필수는 아닙니다. 셰이더 코드를 JavaScript 문자열로 할당하거나, 외부 파일에 작성하여 코드 내에서 가져올 수도 있습니다.
struct VertexOutput {
@builtin(position) clip_position: vec4,
};
@vertex
fn vs_main(
@builtin(vertex_index) in_vertex_index: u32,
) -> VertexOutput {
var out: VertexOutput;
let x = f32(1 - i32(in_vertex_index)) * 0.5;
let y = f32(i32(in_vertex_index & 1u) * 2 - 1) * 0.5;
out.clip_position = vec4(x, y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
return vec4(0.3, 0.2, 0.1, 1.0);
}
우리의 첫 번째 셰이더는 단색으로 삼각형을 렌더링합니다. 간단하게 들리지만, 코드는 처음에는 복잡하게 느껴질 수 있습니다. 그 구성 요소를 더 잘 이해하기 위해 자세히 살펴보겠습니다.
셰이더 프로그램은 GPU 파이프라인의 동작을 정의합니다. GPU 파이프라인은 작은 공장처럼 작동하며, 일련의 단계 또는 작업장을 포함합니다. 일반적인 GPU 파이프라인은 두 가지 주요 단계로 구성됩니다:
정점 스테이지(Vertex Stage): 지오메트리 데이터를 처리하고 캔버스에 정렬된 지오메트리를 생성합니다.
프래그먼트 스테이지(Fragment Stage): GPU가 정점 스테이지의 출력을 프래그먼트로 변환한 후, 프래그먼트 셰이더는 이들에게 색상을 할당합니다.
우리 셰이더 코드에는 두 가지 엔트리 함수가 있습니다:
vs_main:@vertex로 주석이 달린 정점 스테이지를 나타냅니다.fs_main:@fragment로 주석이 달린 프래그먼트 스테이지를 나타냅니다.
vs_main 함수의 입력인 @builtin(vertex_index) in_vertex_index: u32는 C와 같은 언어의 함수 매개변수와 비슷해 보이지만 다릅니다. 여기서 in_vertex_index는 변수 이름이고, u32는 타입(32비트 부호 없는 정수)입니다. @builtin(vertex_index)는 설명이 필요한 특별한 데코레이터입니다.
WGSL에서 셰이더 입력은 진정한 함수 매개변수가 아닙니다. 대신, 각 필드에 레이블이 있는 미리 정의된 양식을 상상해 보세요. @builtin(vertex_index)는 그러한 레이블 중 하나입니다. 파이프라인 스테이지의 입력의 경우, 우리는 어떤 데이터든지 자유롭게 입력할 수 없고, 미리 정의된 세트에서 필드를 선택해야 합니다. 이 경우, @builtin(vertex_index)는 실제 매개변수 이름이며, in_vertex_index는 우리가 부여한 별칭일 뿐입니다.
@builtin 데코레이터는 미리 정의된 필드 그룹을 나타냅니다. 나중에 @location과 같은 다른 데코레이터도 접하게 될 것이며, 이들의 차이점을 이해하기 위해 논의할 것입니다.
셰이더 스테이지 출력도 유사한 원칙을 따릅니다. 임의의 데이터를 출력할 수 없고, 대신 미리 정의된 몇 가지 필드를 채웁니다. 우리 예제에서는 사용자 정의로 보이는 struct VertexOutput을 출력하고 있습니다. 하지만 이 구조체는 결과물을 작성할 @builtin(position)이라는 단일 미리 정의된 필드를 포함하고 있습니다.
정점 셰이더의 내용은 처음에는 혼란스러울 수 있습니다. 이에 대해 자세히 설명하기 전에, 정점 셰이더의 주요 목표를 설명하겠습니다. 정점 셰이더는 기하학 데이터를 개별 정점으로 받습니다. 이 단계에서는 기하학 연결 정보가 부족합니다. 즉, 어떤 정점들이 연결되어 삼각형을 형성하는지 알 수 없습니다. 이 정보는 우리에게 제공되지 않습니다. 우리는 정점들의 위치를 캔버스에 맞게 정렬하는 것을 목표로 개별 정점들을 처리합니다.
이 변환이 없으면 정점들이 올바르게 보이지 않을 것입니다. 정점 셰이더가 받는 정점 위치는 일반적으로 자체 좌표계에서 정의됩니다. 이들을 캔버스에 표시하려면, 입력 정점들이 사용하는 좌표계를 캔버스의 좌표계와 통일해야 합니다. 또한, 정점들은 3D 공간에 존재할 수 있지만, 캔버스는 항상 2D입니다. 컴퓨터 그래픽에서 3D 좌표를 2D로 변환하는 과정을 투영(projection)이라고 합니다.
이제 캔버스의 좌표계를 살펴보겠습니다. 이 시스템은 보통 스크린 공간(screen space) 또는 클립 공간(clip space)이라고 불립니다. WebGPU에서는 일반적으로 화면이 아닌 캔버스에 렌더링하지만, "스크린 공간 좌표계"라는 용어는 다른 기본 3D API에서 계승된 것입니다.
스크린 공간 좌표계는 중심에 원점을 두며, x와 y 좌표는 모두 [-1, 1] 범위 내에 있습니다. 이 좌표계는 화면 또는 캔버스 크기에 관계없이 일정하게 유지됩니다.
이전 튜토리얼에서 뷰포트를 정의할 수 있다고 언급했지만, 이는 좌표계에 영향을 주지 않습니다. 이것은 직관적이지 않을 수 있습니다. 뷰포트 정의와 관계없이 스크린 공간 좌표계는 변경되지 않습니다. 정점은 좌표가 [-1, 1] 범위 내에 있는 한 가시적입니다. 렌더링 파이프라인은 스크린 공간 좌표계를 정의된 뷰포트에 맞게 자동으로 늘립니다. 예를 들어, 뷰포트가 640x480이라면, 종횡비가 4:3임에도 불구하고 캔버스 좌표계는 여전히 x와 y 모두에 대해 [-1, 1] 범위를 가집니다. (1, 1) 위치에 정점을 그리면 오른쪽 상단 모서리에 나타납니다. 그러나 캔버스에 표시될 때는 (1, 1) 위치가 (640, 0)으로 늘어나게 됩니다.
위 코드에서 입력은 정점 위치가 아닌 정점 인덱스입니다. 삼각형에는 세 개의 정점이 있으므로 인덱스는 0, 1, 2입니다. 입력으로 정점 위치가 없으므로 정점 위치 변환을 수행하는 대신 이러한 인덱스를 기반으로 위치를 생성합니다. 우리의 목표는 각 인덱스에 대해 고유한 위치를 생성하는 동시에 해당 위치가 [-1, 1] 범위 내에 들어오도록 하여 전체 삼각형이 보이도록 하는 것입니다. vertex_index에 0, 1, 2를 대입하면 각각 (0.5, -0.5), (0, 0.5), (-0.5, -0.5) 위치를 얻게 됩니다.
let x = f32(1 - i32(in_vertex_index)) * 0.5;
let y = f32(i32(in_vertex_index & 1u) * 2 - 1) * 0.5;클립 위치(클립 공간에서의 위치)는 2개가 아닌 4개의 부동 소수점 벡터로 표현됩니다. 화면 공간에 있는 우리의 2D 삼각형의 경우, 세 번째 구성 요소는 항상 0입니다. 마지막 값은 1.0으로 설정됩니다. 마지막 두 값에 대한 자세한 내용은 나중에 카메라와 행렬 변환을 탐구할 때 다룰 것입니다.
앞서 언급했듯이, 정점 스테이지의 출력은 래스터화 과정을 거칩니다. 이 과정은 보간된 정점 값을 가진 프래그먼트를 생성합니다. 우리의 간단한 예제에서는 유일하게 보간되는 값은 정점 위치입니다.
프래그먼트 셰이더의 출력은 @location(0)이라는 다른 미리 정의된 필드에 의해 정의됩니다. 각 위치는 최대 16바이트의 데이터, 즉 4개의 32비트 부동 소수점 값과 동일한 데이터를 저장할 수 있습니다. 사용 가능한 총 위치 수는 특정 WebGPU 구현에 따라 결정됩니다.
위치(locations)와 빌트인(builtins)의 차이점을 이해하기 위해, 위치를 비구조화된 사용자 정의 데이터로 간주할 수 있습니다. 이들은 인덱스 외에 다른 레이블이 없습니다. 이 개념은 HTTP 프로토콜과 유사하며, 구조화된 메시지 헤더(빌트인과 유사)가 있고 그 뒤에 임의의 데이터를 포함할 수 있는 본문 또는 페이로드(위치와 유사)가 따라옵니다. 이진 파일 디코딩에 익숙하다면, 메타데이터가 있는 구조화된 헤더 뒤에 페이로드로 데이터 덩어리가 오는 것과 비슷합니다. 우리 맥락에서 빌트인과 위치는 이러한 개념적 구조를 공유합니다.
이 예제에서 우리의 프래그먼트 셰이더는 간단합니다. 단순히 단색을 @location(0)으로 출력합니다.
let code = document.getElementById('shader').innerText;
const shaderDesc = { code: code };
let shaderModule = device.createShaderModule(shaderDesc);
셰이더 코드를 작성하는 것은 간단한 삼각형을 렌더링하는 작업의 한 부분일 뿐입니다. 이제 이 셰이더 코드를 통합하기 위해 파이프라인을 어떻게 수정하는지 살펴보겠습니다. 이 과정은 여러 단계로 이루어집니다:
첫 번째 스크립트 태그에서 셰이더 코드 문자열을 가져옵니다. 여기서 태그의
id='shader'속성이 중요합니다.소스 코드가 포함된 셰이더 설명 객체를 구성합니다.
셰이더 설명을 WebGPU API에 제공하여 셰이더 모듈을 생성합니다.
이 예제에서는 오류 처리를 구현하지 않았다는 점에 유의해야 합니다. 컴파일 오류가 발생하면 유효하지 않은 셰이더 모듈이 생성됩니다. 이 경우 브라우저의 콘솔 메시지가 디버깅에 매우 도움이 될 수 있습니다.
일반적으로 셰이더 코드는 개발 단계에서 개발자에 의해 정의되며, 모든 셰이더 문제는 코드가 배포되기 전에 해결될 가능성이 높습니다. 이러한 이유로 이 기본적인 예제에서는 오류 처리를 생략했습니다. 그러나 프로덕션 환경에서는 견고한 오류 검사를 구현하는 것이 좋습니다.
const pipelineLayoutDesc = { bindGroupLayouts: [] };
const layout = device.createPipelineLayout(pipelineLayoutDesc);
다음으로, 파이프라인 레이아웃을 정의합니다. 그런데 파이프라인 레이아웃이란 정확히 무엇일까요? 이는 파이프라인에 제공하려는 상수의 구조를 나타냅니다. 각 레이아웃은 파이프라인에 입력하려는 상수 그룹을 나타냅니다.
파이프라인은 여러 상수 그룹을 가질 수 있으며, 이 때문에 bindGroupLayouts는 리스트로 정의됩니다. 이 상수들은 파이프라인의 실행 내내 그 값을 유지합니다.
현재 예제에서는 어떠한 상수도 제공하지 않습니다. 결과적으로 우리의 파이프라인 레이아웃은 비어 있습니다.
const colorState = {
format: 'bgra8unorm'
};
파이프라인 구성의 다음 단계는 출력 픽셀 형식을 지정하는 것입니다. 이 경우 bgra8unorm을 사용합니다. 이 형식은 렌더링 대상을 어떻게 채울지 정의합니다. 자세히 설명하자면, bgra8unorm은 다음을 의미합니다:
'b', 'g', 'r', 'a': 파랑, 녹색, 빨강, 알파 채널
'8': 각 채널이 8비트를 사용
'unorm': 값이 부호 없고 정규화됨 (0에서 1 사이의 범위)
const pipelineDesc = {
layout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: []
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-list',
frontFace: 'ccw',
cullMode: 'back'
}
};
pipeline = device.createRenderPipeline(pipelineDesc);
필요한 모든 구성 요소를 모았으니 이제 파이프라인을 생성할 수 있습니다. 실제 공장 파이프라인과 유사하게, GPU 파이프라인은 입력, 일련의 처리 단계, 그리고 최종 출력으로 구성됩니다. 이 비유에서 layout과 primitive는 입력 데이터 형식을 설명합니다. 앞서 언급했듯이, layout은 상수를 나타내고, primitive는 기하학적 기본 도형이 어떻게 제공되어야 하는지를 지정합니다.
일반적으로 실제 입력 데이터는 버퍼를 통해 제공됩니다. 이 버퍼에는 일반적으로 정점 위치와 정점 색상, 텍스처 좌표와 같은 다른 속성을 포함하는 정점 데이터가 포함됩니다. 그러나 현재 예제에서는 어떤 버퍼도 사용하지 않습니다. 정점 위치를 직접 공급하는 대신, 정점 셰이더 단계에서 정점 인덱스로부터 정점 위치를 파생합니다. 이 인덱스는 GPU 파이프라인에 의해 정점 셰이더에 자동으로 제공됩니다.
일반적으로 우리는 명시적인 연결 정보 없이 정점 목록으로 입력 지오메트리를 제공하며, 완전한 3D 그래픽 요소인 삼각형 형태로는 제공하지 않습니다. 파이프라인은 topology 필드를 기반으로 이러한 정점들로부터 삼각형을 재구성합니다. 예를 들어, 토폴로지가 triangle-list로 설정되면, 정점 목록이 시계 반대 방향 또는 시계 방향 순서로 삼각형 정점을 나타냄을 의미합니다. 각 삼각형에는 앞면과 뒷면이 있으며, 정점 순서가 삼각형의 앞면 방향을 정의합니다 (frontFace: 'ccw').
cullMode 매개변수는 삼각형의 특정 면을 렌더링에서 제거할지 여부를 결정합니다. back으로 설정하면 삼각형의 뒷면을 렌더링하지 않도록 선택합니다. 대부분의 경우 삼각형의 뒷면은 렌더링될 필요가 없으며, 이를 생략하면 계산 자원을 절약할 수 있습니다.
삼각형 목록 토폴로지를 사용하는 것이 삼각형을 나타내는 가장 간단한 방법이지만, 항상 가장 효율적인 방법은 아닙니다. 다음 다이어그램에 설명된 바와 같이, 연결된 삼각형으로 형성된 스트립을 렌더링하려는 경우, 많은 정점들이 하나 이상의 삼각형에 의해 공유됩니다.
이러한 경우, 우리는 여러 삼각형에 대해 동일한 위치를 중복해서 보내는 대신, 여러 삼각형에 대해 정점 위치를 재사용하기를 원합니다. 이때 삼각형 스트립 토폴로지가 더 나은 선택이 됩니다. 이를 통해 연결된 삼각형 시리즈를 더 효율적으로 정의할 수 있어 데이터 중복을 줄이고 렌더링 성능을 향상시킬 수 있습니다. 향후 챕터에서는 다른 토폴로지 유형을 탐구할 것입니다.
commandEncoder = device.createCommandEncoder();
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setPipeline(pipeline);
passEncoder.draw(3, 1);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
파이프라인이 정의되었으므로, 첫 번째 튜토리얼에서 다룬 내용과 유사한 colorAttachment를 생성해야 합니다. 이 부분은 자세한 설명을 생략하겠습니다. 그 다음 마지막 단계는 커맨드 생성 및 제출입니다. 이 과정은 이전에 했던 것과 거의 동일하며, 새로 생성된 파이프라인을 사용하고 draw() 함수를 호출한다는 점이 주요 차이점입니다.
draw() 함수는 렌더링 프로세스를 시작합니다. 첫 번째 매개변수는 렌더링할 정점의 수를 지정하고, 두 번째 매개변수는 인스턴스 수를 나타냅니다. 단일 삼각형을 렌더링하므로 총 정점 수는 3개입니다. 정점 인덱스는 정점 셰이더를 위해 자동으로 생성됩니다.
인스턴스 수는 삼각형을 몇 번 복제할지 결정합니다. 이 기술은 비디오 게임에서 잔디나 나뭇잎과 같은 동일한 지오메트리를 대량으로 렌더링해야 할 때 렌더링 속도를 높일 수 있습니다. 이 예제에서는 하나의 삼각형만 그리면 되므로 단일 인스턴스를 지정합니다.