메시 셰이더의 기본 개념과 WGSL 문법에 대한 첫 번째 합의가 이루어졌지만, 그것은 어디까지나 화이트보드 위의 약속일 뿐이었다. 진짜 문제는 이 새로운 파이프라인을 실제로 어떻게 구현하고, 어떻게 테스트할 것인가에 있었다.
WebGPU 1.0 시절에는 상황이 달랐다. 렌더 파이프라인은 WebGL이라는 명확한 비교 대상이 있었다. 개발자들은 WebGL 코드를 WebGPU로 옮기면서 두 기술의 차이점을 자연스럽게 학습했다.
하지만 메시 셰이더는 달랐다. 웹 생태계에는 아무런 선례가 없는, 완전히 새로운 개념이었다. 개발자들에게 ‘이것이 메시 셰이더입니다’라고 보여줄, 단 하나의 작동하는 예제조차 존재하지 않았다.
Dawn 팀의 회의실.
벤이 깊은 한숨을 쉬며 말했다.
“우리는 지금 유령과 싸우고 있습니다. 아직 존재하지 않는 기술을 위한 API를 설계하고, 아직 작성되지 않은 코드를 위한 컴파일러를 만들고 있어요. 대체 우리가 올바른 방향으로 가고 있는지 어떻게 알 수 있죠?”
그의 말은 팀 모두의 불안감을 대변하고 있었다. 그들은 지금 지도 없이 미지의 대륙을 탐험하는 개척자와 같았다.
드미트리는 이 교착 상태를 타개할 방법을 고민했다.
“기다릴 수만은 없어. 우리가 직접, 최초의 지도를 그려야 해.”
그는 결단을 내렸다.
“Dawn 팀과 W3C의 논의를 병행하여, 메시 셰이더의 가장 기본적인 프로토타입 구현을 시작한다. 목표는 단 하나. 화면에 삼각형 하나를 띄우는 것. 하지만 이번에는 버텍스 셰이더가 아니라, 메시 셰이더를 이용해서.”
이것은 ‘Hello, Triangle’의 재현이었지만, 그 난이도는 차원이 달랐다.
팀은 두 개의 파트로 나뉘었다.
한쪽에서는 카이를 중심으로, Dawn의 C++ 엔진 내부에 새로운 ‘메시 파이프라인’ 처리 로직을 추가하는 작업에 착수했다. Vulkan, Metal, DirectX 12의 각기 다른 메시 셰이더 API를 하나의 추상화 계층으로 묶는, 고통스러운 작업의 시작이었다.
다른 한쪽에서는 벤과 젊은 엔지니어들이 WGSL 컴파일러를 수정하기 시작했다. @task
와 @mesh
라는 새로운 문법을 인식하고, 이를 각 플랫폼의 네이티브 셰이더 코드(SPIR-V, HLSL, MSL)로 올바르게 번역하는 기능을 추가해야 했다.
드미트리는 이 모든 과정을 조율하며, W3C 그룹에 진행 상황을 공유하고 피드백을 받는 역할을 맡았다.
몇 주가 흘렀다.
연구실은 다시 한번 출시 직전과 같은 치열함으로 가득 찼다.
카이의 팀은 각기 다른 네이티브 API의 미묘한 차이점 때문에 골머리를 앓았다. Vulkan은 Task Shader를 ‘Amplification Shader’라고 불렀고, Metal은 아예 Task Shader에 해당하는 개념이 없어 Mesh Shader만으로 비슷한 로직을 구현해야 했다. 이 모든 차이를 숨기고 개발자에게는 동일한 API를 제공하는 것은, 마치 세 개의 다른 언어로 쓰인 시를 같은 운율의 하나의 시로 번역하는 것과 같았다.
벤의 팀 역시 컴파일러와의 사투를 벌였다. 그들이 만든 WGSL-to-SPIR-V 컴파일러가 생성한 코드가, 특정 버전의 Vulkan 드라이버에서만 계속 유효성 검증에 실패했다. 그들은 드라이버의 버그인지, 자신들의 컴파일러 버그인지 알아내기 위해 며칠 밤을 새워야 했다.
그 모든 혼돈 속에서, 드미트리는 최초의 WGSL 메시 셰이더 코드를 작성하고 있었다.
// 최초의 메시 셰이더. 오직 삼각형 하나만을 그린다.
@group(0) @binding(0) var<uniform> camera: Camera;
// Task Shader는 하나의 작업 그룹만 실행하도록 요청한다.
@task
fn task_main() -> @dispatch_mesh(1, 1, 1) {
// 아무 작업도 하지 않는다.
}
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
};
// Mesh Shader는 3개의 정점과 1개의 삼각형을 직접 생성한다.
@mesh
fn mesh_main() -> @vertex_output(VertexOutput) @primitive_count(1) {
// 정점 데이터와 인덱스를 셰이더 안에서 직접 정의한다.
set_vertex_output(
0,
VertexOutput(camera.projection * camera.view * vec4<f32>(0.0, 0.5, 0.0, 1.0), vec4<f32>(1.0, 0.0, 0.0, 1.0))
);
set_vertex_output(
1,
VertexOutput(camera.projection * camera.view * vec4<f32>(-0.5, -0.5, 0.0, 1.0), vec4<f32>(0.0, 1.0, 0.0, 1.0))
);
set_vertex_output(
2,
VertexOutput(camera.projection * camera.view * vec4<f32>(0.5, -0.5, 0.0, 1.0), vec4<f32>(0.0, 0.0, 1.0, 1.0))
);
set_primitive_indices(0, vec3<u32>(0, 1, 2));
}
CPU로부터 어떤 정점 데이터도 입력받지 않고, 셰이더가 스스로 모든 것을 창조해내는, 완전히 새로운 방식의 코드였다.
마침내 모든 조각이 맞춰졌다.
Dawn의 메시 파이프라인 구현, WGSL 컴파일러, 그리고 최초의 셰이더 코드.
드미트리는 떨리는 마음으로 실행 버튼을 눌렀다.
화면에는 아무것도 나타나지 않았다.
하지만 이번에는 누구도 좌절하지 않았다. 이것은 이미 예상했던 결과였다. 디버거 콘솔 창에는 수십 개의 에러 메시지가 붉은색으로 타오르고 있었다.
“좋아. 이제 시작이군.”
드미트리가 나지막이 말했다.
그들에게는 이제 싸워야 할 ‘적(에러)’이 생겼다. 더 이상 유령과 싸우는 것이 아니었다. 그들은 최초의 실패를 통해, 비로소 자신들이 가야 할 길을 비춰줄 첫 번째 이정표를 얻은 것이다.
황무지의 개척자들은, 마침내 그 땅에 첫 번째 삽을 꽂았다. 비록 그것이 만들어낸 것이 구덩이라 할지라도, 그 구덩이는 미래의 도시를 세울 주춧돌의 위치를 알려주고 있었다.