셰이더 드로잉: 환경(c)

이전 글에서 이어집니다.

***

WGSL

WGSL(WebGPU Shading Language)은 WebGPU API에서 사용하는 셰이딩 프로그래밍 언어다. 화면에 무언가 그리기 위해 버텍스 셰이더(Vertex Shader, 정점 셰이더)와 프래그먼트 셰이더(Fragment Shader)를 사용할 것이다. 정점 셰이더는 화면에 그려낼 도형(Primitive, 프리미티브)의 각 정점(Vertex)을 처리하는 프로그램이다. 처리된 정점은 GPU를 거쳐 래스터화(Rasterization)된다. 래스터화는 정점들이 구성하는 도형이 어떤 픽셀을 덮는지 판단하고, 정점의 속성값을 보간하여 프래그먼트(Fragment)를 생성하는 과정이다. 프래그먼트 셰이더는 GPU로부터 프래그먼트 데이터(위치, 혹은 보간된 값)를 입력받아 RGBA의 색상을 출력하게 된다.

@vertex
fn vertex_main() {

}

@fragment
fn fragment_main() {

}

@vertex와 @fragment가 붙어 있는 함수는 각 셰이더의 엔트리 포인트(Entry Point, 진입 지점)가 된다. 함수명은 특별히 정해져 있지 않지만, 차후 파이프라인의 엔트리 포인트로 연결시키는 데 사용되기에 엉망진창으로 지으면 안 된다.

fn vertex_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
    let positions = array<vec4f, 3>(
        vec4f(-0.5, -0.5, 0.0, 1.0),
        vec4f(0.5, -0.5, 0.0, 1.0),
        vec4f(0.0, 0.5, 0.0, 1.0),
    );

    return positions[index];
}

WGSL 함수는 입력값에 대해 매개변수 이름과 타입을 선언하고, 반환값에 대해서는 반환 타입만 선언한다. 실제 반환값은 return 문(Statement)으로 지정한다. WGSL 기초 문법은 Tour of WGSL을 참고하면 된다. 문법 자체는 Rust와 유사하다.

vertex_main의 입력으로는 @builtin(vertex_index)를 index라는 이름의 u32 타입 값으로 받는다. @builtin은 GPU 파이프라인에 미리 정의된 특별한 값을 의미한다. 이는 입출력 방향과 값에 따라 시스템(파이프라인), 혹은 셰이더가 값을 계산한다. 이를테면 vertex_index는 draw 메서드의 vertices 범위의 현재 처리 중인 정점의 순번을 받아온다. 정점 3개를 사용한다고 정하면 0, 1, 2의 값을 받아오는 셈이며, 정점 셰이더는 정점을 하나씩 병렬로 처리하기에 각 정점에 0, 1, 2라는 값이 입력되고, 이를 index라는 u32 타입의 매개변수로 사용하는 것이다. 타입과 명칭, 기능은 W3C WGSL 문서에서 확인할 수 있다.

현재 vertex_main 함수는 @builtin(position)을 반환하게 정의되었다. 이는 (x, y, z, w)의 f32 값 4개로 이루어진 벡터다. 함수 내에는 각 정점의 위치를 배열로 선언한다. GPU는 각 정점에 대해 정점 셰이더를 독립적으로 실행하며, 각 실행에서는 @builtin(vertex_index)로 전달받은 index를 이용해 배열에서 해당 정점의 위치를 선택하고 반환한다. 이렇게 반환된 값은 클립 공간(Clip Space)의 좌표다.

클립 공간은 정점 셰이더가 출력하는 정점의 위치를 표현하는 좌표계다. 도형이 화면 밖에 있다면 그만큼 잘라서 안 보여줘야 한다. 이를 위해 클립 볼륨(Clip Volume)을 이용한다. 클립 볼륨은 x와 y를 -w에서 w만큼, z를 0에서 w만큼 가지는 영역이다. 이 영역을 벗어난 도형의 부분은 잘리게(Clipping)되는 것이다. 따라서 w는 일종의 원근을 담당한다.

$$-w \le x \le w$$
$$-w \le y \le w$$
$$0 \le z \le w$$

위 영역(클립 볼륨)을 벗어나면 잘린다. 예를 들어 클립 공간 내 임의의 정점의 좌표가 vec4f(1.0, 1.0, 1.0, 2.0)라면 해당 영역 안에 있는 셈이다.

이후 GPU는 각 좌표 성분을 w로 나눠 x와 y는 -1에서 1로, z는 0에서 1로 정규화한다. 이를 정규화된 장치 좌표(Normalized Device Coordinates, NDC)라고 한다. 이렇게 만들어진 NDC를 이용해 화면 해상도에 맞추는 뷰포트 변환(Viewport Transformation) 과정을 거친다. 출력할 해상도가 고정되어 있지 않기에 NDC로 정규화하여 계산하는 것이다. 계산식은 아래와 같다. NDC는 Y축이 위쪽으로 증가하지만, WebGPU의 화면 좌표는 Y축이 아래쪽으로 증가하기에 Y축 방향을 뒤집어 계산한다. 계산은 GPU가 알아서 수행한다.

\[ \begin{aligned} x_{\text{screen}} &= \frac{x_{\text{ndc}}+1}{2}\times\text{width} \ y_{\text{screen}} &= \frac{1-y_{\text{ndc}}}{2}\times\text{height} \end{aligned} \]
\[ \begin{aligned} x &= \frac{0.5+1}{2}\times800=600 \ y &= \frac{1-0.5}{2}\times600=150 \end{aligned} \]

800×600 해상도에서 NDC (0.0, 0.0)은 위 식에 따라 (400.0, 300.0)이 된다. 같은 해상도에서 NDC (0.5, 0.5), 즉 3/4 우측 상단은 (600.0, 150.0)이 되는 셈이다. OpenGL이나 WebGL을 사용할 때는 화면의 우측 상단이 가장 큰 값이었지만 WebGPU는 우측 하단이 가장 큰 값이다. 익숙한 2차원 좌표계와 일관되게 개선한 듯한데, 다소 어지러워 점차 익숙해질 필요가 있을 듯하다.

각 정점은 뷰포트 변환을 거쳐 화면 좌표계 값이 된다. 즉, 화면 어디에 위치해 있는지 알 수 있게 된 것이다. GPU는 이를 이용해 정점이 이루는 형태가 화면 어디를 덮고 있는지 판단하고, 덮여 있다면 프래그먼트를 생성한다. 또, 정점 셰이더가 출력하는 값 중 @builtin(position) 외의 실수형 값이 있다면 생성된 프래그먼트 위치에 맞게 보간한다. 그렇게 만들어진 값들을 프래그먼트 셰이더로 전달하게 된다.

fn fragment_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
    return vec4f(0.0, 0.2, 1.0, 1.0);
}

fragment_main은 vertex_main이 반환한 클립 공간 좌표를 기반으로 GPU가 클리핑, 원근 나눗셈, 뷰포트 변환 및 래스터화를 수행하여 생성한 프래그먼트의 위치 정보를 @builtin(position)을 통해 입력받는다. 출력 위치인 @location(0)은 차후 생성할 파이프라인 targets의 0번째(첫 번째) 설정으로 RenderPass 내에서 정한 color_attachments 배열의 0번째(첫 번째) RenderPassColorAttachment에 연결된다. 지금은 position 값을 사용하지 않고 그저 파란색을 출력할 예정이다.

@vertex
fn vertex_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
    let positions = array<vec4f, 3>(
            vec4f(-0.5, -0.5, 0.0, 1.0),
            vec4f(0.5, -0.5, 0.0, 1.0),
            vec4f(0.0, 0.5, 0.0, 1.0),
        );
    return positions[index];
}

@fragment
fn fragment_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
    return vec4f(0.0, 0.2, 1.0, 1.0);
}

일단 정점 3개의 파란색 삼각형을 그리는 WGSL 코드 전체를 완성했다.

파이프라인

let source = include_str!("shader.wgsl");

let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
    label: Some("shader_module"),
    source: wgpu::ShaderSource::Wgsl(source.into()),
});

WGSL 파일을 작성했다면, 이를 Rust 파일에 불러와 RenderPipeline을 만들어야 한다. 작성한 WGSL 파일은 현재 상황에선 include_str! 매크로를 이용해 불러온다. include_str!은 파일의 상대경로를 입력받아 외부 파일 내용을 Rust 프로그램에 &str 타입으로 포함시킨다. 이를 create_shader_module메서드에 ShaderModuleDescriptor의 source로 입력해 ShaderModule을 만든다.

let render_pipeline = device.create_render_pipeline(
    &wgpu::RenderPipelineDescriptor {
        label: Some("render_pipeline"),
        layout: None,
        vertex: wgpu::VertexState {
            module: &shader_module,
            entry_point: Some("vertex_main"),
            compilation_options: Default::default(),
            buffers: &[],
        },
        primitive: wgpu::PrimitiveState {
            topology: wgpu::PrimitiveTopology::TriangleList,
            cull_mode: None,
            ..Default::default()
        },
        depth_stencil: None,
        multisample: Default::default(),
        fragment: Some(wgpu::FragmentState {
            module: &shader_module,
            entry_point: Some("fragment_main"),
            compilation_options: Default::default(),
            targets: &[Some(wgpu::ColorTargetState {
                format: config.format,
                blend: None,
                write_mask: wgpu::ColorWrites::ALL,
            })],
        }),
        multiview_mask: None,
        cache: None,
    },
);

RenderPipeline은 create_render_pipeline 메서드에 RenderPipelineDescriptor 설정을 입력해 생성한다. 각 설정은 당장 사용하지 않는 것도 포함되어있다. 설정 중 특히 중요한 부분은 vertex와 fragment, 그리고 primitive다.

vertex 설정에는 VertexState가 들어간다. 생성한 ShaderModule을 module에, 정점 셰이더 엔트리 포인트 함수 이름을 entry_point에 입력하면 된다. compilation_options는 웬만해선 기본 설정으로 상관 없다 하니 넘어가고, 현재는 정점 정보가 정점 셰이더 함수 내부에 포함되어있으니 buffers도 비워둔다.

fragment 설정에는 FragmentState가 들어간다. 마찬가지로 ShaderModule과 엔트리 포인트 함수 이름을 입력, compilation_options도 기본 설정으로 둔다. targets는 프래그먼트 셰이더가 출력하는 색상값의 경로의 render target이 사용할 설정이다. @location(0)으로 출력한다면 0번(첫 번째) 설정을 적용시킨다. format은 TextureFormat, 즉 색상값을 저장하는 형식을 정한다. SurfaceConfiguration의 저장 형식을 사용해 출력할 화면의 형식과 일치시킨다. blend는 기존 픽셀 색상과 셰이더 출력 색상을 어떻게 섞을지 정한다. None은 프래그먼트 셰이더 출력이 기존 픽셀을 완전 대체한다. write_mask는 출력 색상을 어떤 채널에 기록할지 정한다. ALL은 RGBA 전부 사용한다는 의미다.

primitive 설정에는 PrimitiveState가 들어간다. topology는 프리미티브의 정점, 선, 면이 어떻게 연결되는지를 의미하며, PrimitiveTopology를 입력받는다. TriangleList는 정점 세 개마다 독립된 삼각형을 만든다. TriangleStrip은 인접한 삼각형의 정점 두 개를 재활용한다. 예를 들어 삼각형 하나가 있고, 정점 하나를 추가하면 기존 삼각형의 인접한 정점 두 개와 함께 삼각형을 만드는 식이다. 현재로썬 삼각형 하나만 그릴 것이기에 TriangleList로 설정한다. cull_mode는 삼각형의 앞면과 뒷면 중 어떤 면을 렌더하지 않을지 정한다. 입체 도형을 그릴 때 보여지지 않는 면을 전부 그릴 필요는 없기에 존재하는 설정이다. 삼각형 정점 순서가 시계 방향인지 여부로 앞뒷면 여부를 판단해 적용된다. 지금은 입체 도형을 그리기 않기에 None으로 설정해 앞뒷면 모두 그리게 한다.

pub struct GpuState {
    surface: wgpu::Surface<'static>,
    device: wgpu::Device,
    queue: wgpu::Queue,
    config: wgpu::SurfaceConfiguration,
    size: winit::dpi::PhysicalSize<u32>,
    render_pipeline: wgpu::RenderPipeline,
}

GPU 구조체에 RenderPipeline을 담을 필드를 추가한다.

Ok(GpuState {
    surface,
    device,
    queue,
    config,
    size,
    render_pipeline,
})

초기화 시 생성한 RenderPipeline을 포함시킨다.

{
    let mut render_pass = command_encoder.begin_render_pass(
        &wgpu::RenderPassDescriptor {
            label: Some("asdf"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: &view,
                depth_slice: None,
                resolve_target: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Clear(wgpu::Color {
                        r: 1.0,
                        g: 0.1,
                        b: 0.0,
                        a: 1.0,
                    }),
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: None,
            timestamp_writes: None,
            occlusion_query_set: None,
            multiview_mask: None,
        },
    );

    render_pass.set_pipeline(&self.render_pipeline);
    render_pass.draw(0..3, 0..1);
}

render_pass를 mutable로 바꾸고, set_pipeline으로 GpuState에 보관한 RenderPipeline을 RenderPass에 연결한다. 이후 draw로 정점 인덱스 범위와 인스턴스(Instance)를 정해 몇 개를 그릴지 정한다.

다음 글로 이어집니다.