첫 번째 WebGPU 앱

1. 소개

WebGPU 로고는 양식화된 'W'를 형성하는 여러 개의 파란색 삼각형으로 구성됩니다.

최종 업데이트: 2023년 8월 28일

WebGPU란?

WebGPU는 웹 앱에서 GPU의 기능에 액세스하기 위한 새로운 최신 API입니다.

최신 API

WebGPU 이전에는 WebGPU 기능의 하위 집합을 제공하는 WebGL이 있었습니다. 이를 통해 새로운 유형의 리치 웹 콘텐츠를 만들 수 있었으며 개발자들은 이 기술로 놀라운 콘텐츠를 만들었습니다. 하지만 2007년에 출시된 OpenGL ES 2.0 API를 기반으로 했으며 훨씬 더 오래된 OpenGL API를 기반으로 했습니다. 그때 GPU가 크게 발전하여 Direct3D 12, Metal, Vulkan과 함께 GPU와 상호작용하는 데 사용되는 네이티브 API도 발전했습니다.

WebGPU는 이러한 최신 API의 여러 개선사항을 웹 플랫폼에 적용합니다. 크로스 플랫폼 방식으로 GPU 기능을 사용 설정하는 데 초점을 맞추고, 웹에서 자연스럽게 느껴지고 그 위에 구축된 일부 네이티브 API보다 간결한 API를 제공합니다.

렌더링

GPU는 빠르고 상세한 그래픽 렌더링과 연관된 경우가 많으며 WebGPU도 예외는 아닙니다. 데스크톱과 모바일 GPU에서 오늘날 가장 많이 사용되는 렌더링 기술을 지원하는 데 필요한 기능을 갖추고 있으며 하드웨어 기능이 계속 발전함에 따라 향후 새로운 기능을 추가할 수 있습니다.

컴퓨팅

WebGPU를 사용하면 렌더링 외에도 일반적이고 병렬 처리 가능한 워크로드에서 GPU를 활용할 수 있습니다. 이러한 컴퓨팅 셰이더는 독립형으로, 렌더링 구성요소 없이 사용하거나, 렌더링 파이프라인의 긴밀히 통합된 부분으로 사용할 수 있습니다.

오늘 Codelab에서는 간단한 소개 프로젝트를 만들기 위해 WebGPU의 렌더링 및 컴퓨팅 기능을 모두 활용하는 방법을 학습하겠습니다.

빌드할 항목

이 Codelab에서는 WebGPU를 사용하여 Conway's Game of Life를 빌드합니다. 이 앱에는 아래의 기능이 있습니다.

  • WebGPU의 렌더링 기능을 사용하여 간단한 2D 그래픽을 그립니다.
  • WebGPU의 컴퓨팅 기능을 사용하여 시뮬레이션을 수행합니다.

이 Codelab의 최종 결과를 보여주는 스크린샷

Game of Life는 셀룰러 오토마타라고 하며, 일부 규칙에 따라 시간이 지남에 따라 의 그리드가 상태를 변경하는 것입니다. Game of Life 셀은 인접한 셀의 수가 많을 때 활성 또는 비활성 상태가 되어 동영상을 시청하는 동안 흥미로운 패턴이 발생합니다.

학습할 내용

  • WebGPU 설정 및 캔버스 구성 방법
  • 간단한 2D 도형을 그리는 방법
  • 그리는 내용을 수정하기 위해 꼭짓점 및 프래그먼트 셰이더를 사용하는 방법
  • 간단한 시뮬레이션을 수행하기 위해 컴퓨팅 셰이더를 사용하는 방법

이 Codelab에서는 WebGPU의 기본 개념을 소개합니다. 이 API를 종합적으로 검토하기 위한 것이 아니며 3D 행렬 수학 등 자주 쓰이는 주제를 다루지도 않습니다.

필요한 항목

  • ChromeOS, macOS, Windows의 최신 버전 Chrome(113 이상). WebGPU는 교차 브라우저, 크로스 플랫폼 API이지만 아직 모든 곳에 출시되지는 않았습니다.
  • HTML, JavaScript, Chrome DevTools에 관한 지식

WebGL, Metal, Vulkan, Direct3D와 같은 다른 그래픽 API에 익숙할 필요는 없습니다. 그러나 사용해 본 경험이 있으면 WebGPU와 유사하여 학습을 시작하는 데 도움이 될 수 있습니다.

2. 설정

코드 가져오기

이 Codelab에는 종속 항목이 없으며 WebGPU 앱을 만드는 데 필요한 모든 단계를 안내하므로 시작하는 데 코드가 필요하지 않습니다. 하지만 https://glitch.com/edit/#!/your-first-webgpu-app에서 체크포인트 역할을 할 수 있는 몇 가지 실무 예시를 확인할 수 있습니다. 문제가 발생하면 이 예시를 확인하고 참조하면 됩니다.

개발자 콘솔 사용

WebGPU는 적절한 사용을 시행하는 규칙이 많은 매우 복잡한 API입니다. 더 안 좋은 점은 API의 작동 방식 때문에 많은 오류에 대해 일반적인 JavaScript 예외를 발생시킬 수 없어 문제의 원인을 정확히 파악하기 어려워지기 때문입니다.

특히 초급자가 WebGPU로 개발할 때 문제가 발생하지만 괜찮습니다. API 관련 개발자들은 GPU 개발과 관련된 어려움에 대해 잘 알고 있으며, WebGPU 코드로 인해 오류가 발생할 때마다 사용자가 개발자 콘솔에서 문제를 파악하고 해결하는 데 도움이 되는 매우 상세하고 유용한 메시지를 받을 수 있도록 최선을 다했습니다.

모든 웹 애플리케이션에서 작업하는 동안 콘솔을 열어두면 항상 유용하지만 여기에도 적용됩니다.

3. WebGPU 초기화

<canvas>로 시작

계산만 하려는 경우 화면에 아무것도 표시하지 않고 WebGPU를 사용할 수 있습니다. 그러나 이 Codelab에서 하는 것처럼 무언가를 렌더링하려면 캔버스가 필요합니다. 이제 시작해 볼까요?

캔버스 요소를 쿼리하는 단일 <canvas> 요소와 단일 <script> 태그가 포함된 새 HTML 문서를 만듭니다. (또는 글리치의 00-starter-page.html을 사용하세요.)

  • 다음 코드를 사용하여 index.html 파일을 만듭니다.

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

어댑터 및 기기 요청하기

이제 WebGPU 비트를 시작할 수 있습니다. 먼저 WebGPU 같은 API가 전체 웹 생태계에 전파되는 데 시간이 걸릴 수 있다는 점을 고려해야 합니다. 따라서 사용자의 브라우저가 WebGPU를 사용할 수 있는지 확인하는 것이 좋습니다.

  1. WebGPU의 진입점 역할을 하는 navigator.gpu 객체가 있는지 확인하려면 다음 코드를 추가합니다.

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

WebGPU를 사용할 수 없는 경우 WebGPU를 사용하지 않는 모드로 대체하여 사용자에게 알리는 것이 좋습니다. (대신 WebGL을 사용할 수도 있습니다.) 그러나 이 Codelab에서는 코드가 더 이상 실행되지 않도록 오류를 발생시킵니다.

브라우저에서 WebGPU를 지원한다는 사실을 알고 있다면 앱의 WebGPU를 초기화하는 첫 번째 단계로 GPUAdapter를 요청해야 합니다. 어댑터는 기기의 특정 GPU 하드웨어를 WebGPU가 표현한 것이라고 생각하면 됩니다.

  1. 어댑터를 가져오려면 navigator.gpu.requestAdapter() 메서드를 사용합니다. 프로미스를 반환하므로 await로 호출하는 것이 가장 편리합니다.

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

적절한 어댑터를 찾을 수 없으면 반환된 adapter 값이 null일 수 있으므로 이 가능성에 대비해야 합니다. 이는 사용자의 브라우저가 WebGPU를 지원하지만 GPU 하드웨어에 WebGPU를 사용하는 데 필요한 모든 기능이 없는 경우 발생할 수 있습니다.

대부분의 경우 여기에서와 같이 브라우저에서 기본 어댑터를 선택하도록 하는 것이 괜찮지만 고급 기능이 필요한 경우 requestAdapter()전달할 수 있는 인수를 사용하면 여러 GPU가 있는 기기(예: 일부 노트북)에서 저전력 하드웨어 또는 고성능 하드웨어를 사용할지 여부를 지정할 수 있습니다.

어댑터가 있는 경우 GPU 작업을 시작하기 위한 마지막 단계는 GPUDevice를 요청하는 것입니다. 기기는 GPU와의 대부분의 상호작용이 발생하는 기본 인터페이스입니다.

  1. 프로미스를 반환하는 adapter.requestDevice()를 호출하여 기기를 가져옵니다.

index.html

const device = await adapter.requestDevice();

requestAdapter()와 마찬가지로 특정 하드웨어 기능을 사용 설정하거나 더 높은 한도를 요청하는 등의 고급 사용을 위해 전달될 수 있는 옵션이 있지만 목적에 따라 기본값은 잘 작동합니다.

캔버스 구성하기

이제 기기가 있으므로 페이지에 항목을 표시하기 위해 기기를 사용하려면 방금 만든 기기에서 캔버스를 사용하도록 설정하면 됩니다.

  • 이렇게 하려면 먼저 canvas.getContext("webgpu")를 호출하여 캔버스에서 GPUCanvasContext를 요청합니다. (이는 각각 2dwebgl 컨텍스트 유형을 사용하여 캔버스 2D 또는 WebGL 컨텍스트를 초기화하는 데 사용하는 것과 동일합니다.) 그런 다음 반환하는 context는 다음과 같이 configure() 메서드를 사용하여 기기와 연결해야 합니다.

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

여기서 전달할 수 있는 몇 가지 옵션이 있지만 가장 중요한 옵션은 컨텍스트를 사용할 device 및 컨텍스트에서 사용해야 하는 텍스처 형식format입니다.

텍스처는 WebGPU가 이미지 데이터를 저장하는 데 사용하는 객체이며, 각 텍스처에는 해당 데이터가 메모리에 배치되는 방식을 GPU에 알리는 형식이 있습니다. 텍스처 메모리의 작동 방식에 관한 자세한 내용은 이 Codelab의 범위를 벗어납니다. 캔버스 컨텍스트는 코드로 그릴 수 있는 텍스처를 제공하므로 사용하는 형식이 캔버스의 이미지 효율에 영향을 줄 수 있습니다. 기기 유형에 따라 텍스처 형식이 다를 때 성능이 가장 우수하며, 기기의 기본 형식을 사용하지 않으면 이미지가 페이지의 일부로 표시되기 전에 백그라운드에서 추가 메모리 사본이 생성될 수 있습니다.

다행히도 걱정할 필요가 없습니다. WebGPU에서 캔버스에 사용할 형식을 알려주기 때문입니다. 대부분의 경우 위와 같이 navigator.gpu.getPreferredCanvasFormat()를 호출하여 반환된 값을 전달하려고 합니다.

캔버스 지우기

이제 기기가 준비되고 캔버스가 구성되었으므로 기기를 사용하여 캔버스의 콘텐츠를 변경할 수 있습니다. 시작하려면 우선 단색으로 지웁니다.

그렇게 하려면(또는 WebGPU의 다른 여러 작업을 실행하려면) GPU에 무엇을 해야 하는지 알려주는 몇 가지 명령어를 제공해야 합니다.

  1. 이를 위해 기기에서 GPU 명령어를 기록하기 위한 인터페이스를 제공하는 GPUCommandEncoder를 생성하도록 합니다.

index.html

const encoder = device.createCommandEncoder();

GPU로 전송하려는 명령어는 렌더링과 관련이 있습니다(이 경우에는 캔버스 지우기). 다음 단계는 encoder를 사용하여 렌더 패스를 시작하는 것입니다.

렌더 패스는 WebGPU의 모든 그리기 작업이 발생하는 시점을 말합니다. 각 렌더 패스는 beginRenderPass() 호출로 시작하며, 이 메서드는 실행된 그리기 명령어의 출력을 수신하는 텍스처를 정의합니다. 고급 사용에서는 렌더링된 도형의 깊이를 저장하거나 앤티에일리어싱을 제공하는 등 다양한 목적으로 첨부파일이라고 하는 여러 텍스처를 제공할 수 있습니다. 하지만 이 앱에는 하나만 필요합니다.

  1. context.getCurrentTexture()를 호출하여 이전에 만든 캔버스 컨텍스트에서 텍스처를 가져옵니다. 이 메서드는 캔버스의 widthheight 속성과 일치하는 픽셀 너비 및 높이, context.configure()를 호출할 때 지정된 format를 포함하는 텍스처를 반환합니다.

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

텍스처는 colorAttachmentview 속성으로 제공됩니다. 렌더 패스를 사용하려면 GPUTexture 대신 GPUTextureView를 제공해야 합니다. 이를 통해 텍스처의 어떤 부분을 렌더링할지 알 수 있습니다. 이는 고급 사용 사례에서만 중요합니다. 따라서 여기서는 텍스처에 인수가 없는 createView()를 호출하여 렌더 패스에서 전체 텍스처를 사용하려고 한다는 것을 나타냅니다.

또한 렌더 패스가 시작 및 종료될 때 텍스처로 원하는 작업을 지정해야 합니다.

  • loadOp 값이 "clear"이면 렌더 패스가 시작될 때 텍스처를 지울 것임을 나타냅니다.
  • storeOp 값이 "store"이면 렌더 패스가 완료되면 렌더 패스 중에 그리는 결과가 텍스처에 저장된다는 의미입니다.

렌더 패스가 시작되면 아무것도 할 수 없습니다. 아직은 아닙니다. loadOp: "clear"로 렌더 패스를 시작하는 것만으로 텍스처 뷰와 캔버스를 지우는 것으로 충분합니다.

  1. beginRenderPass() 바로 뒤에 다음 호출을 추가하여 렌더 패스를 종료합니다.

index.html

pass.end();

이러한 호출을 수행하는 것만으로는 GPU가 실제로 아무것도 수행하지 않는 것을 알아두세요. GPU에서 나중에 실행하기 위한 명령어만 기록합니다.

  1. GPUCommandBuffer를 만들려면 명령어 인코더에서 finish()를 호출합니다. 명령어 버퍼는 기록된 명령어의 불투명 핸들입니다.

index.html

const commandBuffer = encoder.finish();
  1. GPUDevicequeue를 사용하여 GPU에 명령어 버퍼를 제출합니다. 큐는 모든 GPU 명령어를 실행하여 실행 순서가 올바르고 적절하게 동기화되도록 합니다. 큐의 submit() 메서드는 명령어 버퍼 배열을 취하지만 이 경우에는 배열이 하나뿐입니다.

index.html

device.queue.submit([commandBuffer]);

명령어 버퍼를 제출한 후에는 다시 사용할 수 없으므로 유지할 필요가 없습니다. 더 많은 명령어를 제출하려면 또 다른 명령어 버퍼를 빌드해야 합니다. 따라서 이 Codelab의 샘플 페이지에서와 같이 이 두 단계가 하나로 축소되는 것이 일반적입니다.

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

명령어를 GPU에 제출한 후 JavaScript가 브라우저에 제어권을 반환하도록 합니다. 이 시점에서 브라우저에서 컨텍스트의 현재 텍스처가 변경되었음을 확인한 다음 캔버스를 업데이트하여 해당 텍스처를 이미지로 표시합니다. 이후에 캔버스 콘텐츠를 다시 업데이트하려면 새 명령어 버퍼를 기록하고 제출하여 context.getCurrentTexture()를 다시 호출하고 렌더 패스의 새 텍스처를 가져와야 합니다.

  1. 페이지를 새로고침합니다. 캔버스가 검은색으로 채워져 있습니다. 축하합니다. 이는 첫 번째 WebGPU 앱이 성공적으로 생성되었음을 의미합니다.

검은색 캔버스는 WebGPU를 사용해 캔버스 콘텐츠를 성공적으로 지웠음을 나타냅니다.

색상 선택

솔직히 말하면 검은색 사각형은 꽤 지루합니다. 다음 섹션으로 넘어가기 전에 잠시 시간을 내어 맞춤설정해 보세요.

  1. device.beginRenderPass() 호출에서 다음과 같이 clearValue가 있는 새 줄을 colorAttachment에 추가합니다.

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

clearValue는 패스 시작 시 clear 작업을 실행할 때 어떤 색상을 사용해야 할지 렌더 패스에 지시합니다. 전달된 사전에는 빨간색의 경우 r, 녹색의 경우 g, 파란색의 경우 b, 알파(투명도)의 경우 a의 4가지 값이 포함되어 있습니다. 각 값의 범위는 0~1이며, 이 색상 채널의 값을 함께 설명합니다. 예를 들면 다음과 같습니다.

  • { r: 1, g: 0, b: 0, a: 1 }은 밝은 빨간색입니다.
  • { r: 1, g: 0, b: 1, a: 1 }은 밝은 보라색입니다.
  • { r: 0, g: 0.3, b: 0, a: 1 }은 진한 녹색입니다.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 }은 중간 회색입니다.
  • { r: 0, g: 0, b: 0, a: 0 }은 기본적으로 투명한 검은색입니다.

이 Codelab의 예시 코드와 스크린샷에서는 진한 파란색을 사용하지만 원하는 색상을 자유롭게 선택할 수 있습니다.

  1. 색상을 선택한 후 페이지를 새로고침합니다. 선택한 색상이 캔버스에 표시됩니다.

기본 지우기 색상의 변경 방법을 보여주기 위해 진한 파란색으로 지워진 캔버스입니다.

4. 도형 그리기

이 섹션을 마치면 앱에서 간단한 도형(색상이 있는 정사각형)을 그립니다. 이처럼 단순한 출력에서는 많은 작업이 필요할 것 같다는 경고를 받게 되는데, 이는 WebGPU가 많은 형태의 도형을 매우 효율적으로 렌더링하도록 설계되었기 때문입니다. 이러한 효율성의 부작용은 비교적 간단한 작업을 할 때 비정상적으로 어렵게 느껴질 수 있지만, WebGPU 같은 API를 사용해야 하는 경우라면 조금 더 복잡한 작업을 해야 한다는 것을 예상할 수 있습니다.

GPU의 그리기 방식 이해하기

더 많은 코드를 변경하기 전에 GPU가 화면에 표시되는 도형을 만드는 방법을 매우 간단하고 간략하게 개략적으로 살펴보는 것이 좋습니다. (GPU 렌더링의 작동 방식에 관한 기본 사항을 이미 알고 있는 경우 꼭짓점 정의 섹션으로 건너뛰어도 됩니다.)

다양한 도형과 옵션을 즉시 사용할 수 있는 캔버스 2D 등의 API와는 달리 GPU는 실제로 몇 가지 유형의 도형(또는 WebGPU에서 지칭하는 프리미티브)(점, 선, 삼각형)만 처리합니다. 이 Codelab에서는 삼각형만 사용합니다.

GPU는 예측 가능한 효율적인 방식으로 쉽게 처리할 수 있도록 훌륭한 수학적 속성이 많기 때문에 거의 삼각형으로만 작동합니다. GPU로 그리는 대부분의 도형은 GPU로 그릴 수 있도록 삼각형으로 나눠야 하며, 이러한 삼각형은 모서리로 정의해야 합니다.

이러한 점 또는 꼭짓점은 WebGPU 또는 유사한 API로 정의된 데카르트 좌표계의 한 점을 정의하는 X, Y, (3D 콘텐츠) Z 값으로 정의됩니다. 좌표계의 구조는 페이지의 캔버스와 어떤 관계가 있는지에 대해 가장 쉽게 고려할 수 있습니다. 캔버스의 너비 또는 높이에 관계없이 X축에서는 왼쪽 가장자리가 항상 -1이고 오른쪽 가장자리는 X축에서 항상 +1입니다. 마찬가지로 하단 가장자리는 Y축에서 항상 -1이고 상단 가장자리는 Y축에서 +1입니다. 즉, (0, 0)은 항상 캔버스의 중심이고 (-1, -1)은 항상 왼쪽 하단이며 (1, 1)은 항상 오른쪽 상단입니다. 이를 클립 공간이라고 합니다.

정규화된 기기 좌표 공간을 간단하게 시각화한 그래프입니다.

처음에 꼭짓점은 이 좌표계에 정의되는 경우가 드물기 때문에 GPU는 꼭짓점 셰이더라고 하는 작은 프로그램을 사용하여 꼭짓점을 클립 공간으로 변환하는 데 필요한 수학과 꼭짓점을 그리는 데 필요한 기타 계산을 수행합니다. 예를 들어 셰이더가 일부 애니메이션을 적용하거나 꼭짓점에서 광원까지의 방향을 계산할 수 있습니다. 이러한 셰이더는 WebGPU 개발자에 의해 작성되며 GPU의 작동 방식을 놀라울 정도로 제어합니다.

여기서 GPU는 변환된 꼭짓점에 의해 생성된 모든 삼각형을 가져와 화면의 어떤 픽셀이 삼각형을 그리는 데 필요한지 결정합니다. 그런 다음 개발자가 작성하는 프래그먼트 셰이더라는 또 다른 작은 프로그램을 실행하여 각 픽셀의 색상을 계산합니다. 이러한 계산은 녹색으로 반환하는 것처럼 간단하거나 안개에 의해 필터링되고 표면의 금속성 정도에 따라 수정되는 것처럼 다른 주변 표면에서 반사된 햇빛에 대한 표면의 각도를 계산하는 것처럼 복잡할 수도 있습니다. 이는 전적으로 제어할 수 있으며, 힘을 발휘할 수도 있고 부담스러울 수도 있습니다.

그런 다음 픽셀 색상의 결과를 텍스처로 쌓아 화면에 표시할 수 있습니다.

꼭짓점 정의

앞서 언급했듯이 Game of Life 시뮬레이션은 의 그리드로 표시됩니다. 앱에는 그리드를 시각화하여 활성 셀과 비활성 셀을 구분할 방법이 필요합니다. 이 Codelab에서는 활성 셀에 색이 있는 정사각형을 그리고 비활성 셀을 비워두는 방법을 사용합니다.

즉, 정사각형의 네 모서리에 각각 하나씩 총 4개의 포인트를 GPU에 제공해야 합니다. 예를 들어 캔버스 중앙에 그려진 정사각형이 가장자리에서 여러 방향으로 당겨지면 다음과 같은 모서리 좌표가 생성됩니다.

정사각형의 모서리의 좌표를 표시하는 정규화된 기기 좌표 그래프

이러한 좌표를 GPU에 피드하려면 값을 TypedArray에 배치해야 합니다. 생소한 분들을 위해 소개하자면, TypedArray는 연속된 메모리 블록을 할당하고 시리즈의 각 요소를 특정 데이터 유형으로 해석할 수 있게 하는 JavaScript 객체 그룹입니다. 예를 들어 Uint8Array에서 배열의 각 요소는 서명되지 않은 단일 바이트입니다. TypedArray는 메모리 레이아웃에 민감한 API(예: WebAssembly, WebAudio, WebGPU)와 데이터를 주고받을 때 유용합니다.

정사각형의 경우 값이 분수이므로 Float32Array가 적합합니다.

  1. 코드에 다음 배열 선언을 배치하여 다이어그램의 모든 꼭짓점 위치를 포함하는 배열을 만듭니다. 상단에는 context.configure() 호출 바로 아래에 배치하면 좋습니다.

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

공백과 주석은 값에 영향을 미치지 않으며, 단지 사용자의 편의를 위한 것이며 가독성을 높이기 위한 것입니다. 모든 값 쌍이 한 꼭짓점의 X 및 Y 좌표를 구성하는 것을 확인할 수 있습니다.

하지만 문제가 있습니다. GPU가 삼각형이라는 측면에서 작동하는 것을 기억하시나요? 즉, 꼭짓점을 3개씩 그룹으로 제공해야 합니다. 꼭짓점이 네 개인 그룹이 있다고 가정해 보겠습니다. 해결책은 꼭짓점 두 개를 반복하여 정사각형 중간에 가장자리를 공유하는 삼각형 두 개를 만드는 것입니다.

정사각형의 네 꼭짓점을 사용하여 두 개의 삼각형을 만드는 방법을 보여주는 다이어그램.

다이어그램에서 정사각형을 만들려면 꼭짓점 (-0.8, -0.8)과 (0.8, 0.8) 꼭짓점을 두 번, 즉 파란색 삼각형에 한 번, 빨간색 삼각형에 한 번 나열해야 합니다. (정사각형을 다른 두 모서리로 분할해도 되지만 차이가 없습니다.)

  1. 이전 vertices 배열을 다음과 같이 업데이트합니다.

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

이 다이어그램은 두 삼각형 간의 구분을 명확히 하지만, 꼭짓점 위치는 정확히 동일하며 GPU는 간격 없이 렌더링합니다. 단일 정사각형으로 렌더링됩니다.

꼭짓점 버퍼 만들기

GPU는 JavaScript 배열의 데이터로 꼭짓점을 그릴 수 없습니다. GPU에는 렌더링에 고도로 최적화된 자체 메모리가 있는 경우가 많으므로, GPU가 그리는 동안 사용할 데이터는 해당 메모리에 배치되어야 합니다.

꼭짓점 데이터를 비롯한 많은 값의 경우 GPU 측 메모리는 GPUBuffer 객체를 통해 관리됩니다. 버퍼는 GPU에서 쉽게 액세스할 수 있고 특정 용도로 플래그가 지정된 메모리 블록입니다. GPU가 보이는 TypedArray와 약간 비슷하다고 생각하면 됩니다.

  1. 꼭짓점을 보유할 버퍼를 만들려면 vertices 배열을 정의한 후 다음 호출을 device.createBuffer()에 추가합니다.

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

가장 먼저 생각할 부분은 버퍼에 라벨을 부여하는 것입니다. 만드는 모든 단일 WebGPU 객체에 선택적인 라벨이 부여될 수 있으며, 그렇게 하는 것이 좋습니다. 라벨은 해당 객체가 무엇인지 식별하는 데 도움이 되는 원하는 문자열입니다. 문제가 발생하면 WebGPU에서 발생하는 오류 메시지에 문제가 무엇인지 이해하는 데 도움이 됩니다.

그런 다음 버퍼 크기를 바이트 단위로 지정합니다. 48바이트의 버퍼가 필요하며 32비트 부동 크기(4바이트)에 vertices 배열의 부동 소수점 수(12)를 곱하여 결정합니다. 다행히 TypedArray에서 이미 byteLength를 계산하므로 버퍼를 만들 때 이를 사용할 수 있습니다.

마지막으로 버퍼의 사용량을 지정해야 합니다. GPUBufferUsage 플래그 중 하나 이상이며 여러 플래그가 |(비트별 OR) 연산자와 결합합니다. 이 경우 버퍼를 꼭짓점 데이터(GPUBufferUsage.VERTEX)에 사용하고 데이터를 꼭짓점(GPUBufferUsage.COPY_DST)에 복사할 수 있도록 지정합니다.

반환되는 버퍼 객체는 불투명하므로 보유하고 있는 데이터를 (쉽게) 검사할 수 없습니다. 또한 대부분의 속성은 변경할 수 없습니다. GPUBuffer를 만든 후에는 크기를 조절할 수 없으며 사용 플래그를 변경할 수도 없습니다. 메모리의 내용을 변경할 수 있습니다.

버퍼가 처음 생성되면 여기에 포함된 메모리는 0으로 초기화됩니다. 콘텐츠를 변경하는 방법에는 여러 가지가 있지만 가장 쉬운 방법은 복사하려는 TypedArray를 사용하여 device.queue.writeBuffer()를 호출하는 것입니다.

  1. 꼭짓점 데이터를 버퍼의 메모리에 복사하려면 다음 코드를 추가합니다.

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

꼭짓점 레이아웃 정의

이제 꼭짓점 데이터가 있는 버퍼를 만들었지만 GPU와 관련해서는 바이트의 blob에 불과합니다. 무언가를 그리려면 추가 정보를 제공해야 합니다. 꼭짓점 데이터 구조에 관한 자세한 정보를 WebGPU에 알려줄 수 있어야 합니다.

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

언뜻 보기에는 혼란스러울 수 있지만 비교적 쉽게 분류할 수 있습니다.

가장 먼저 제공하는 항목은 arrayStride입니다. GPU가 다음 꼭짓점을 찾을 때 버퍼에서 앞으로 건너뛰어야 하는 바이트 수입니다. 정사각형의 각 꼭짓점은 32비트 부동 소수점 수 2개로 구성됩니다. 앞서 언급했듯이 32비트 부동 소수점 수는 4바이트이므로 두 부동 소수점 수는 8바이트입니다.

다음은 배열인 attributes 속성입니다. 속성은 각 꼭짓점으로 인코딩된 개별 정보입니다. 꼭짓점에는 하나의 속성(꼭짓점 위치)만 포함되지만, 고급 사용 사례에서는 꼭짓점 색상 또는 도형 표면이 가리키는 방향과 같이 속성이 여러 개 있는 꼭짓점이 있는 경우가 많습니다. 하지만 이 Codelab에서는 다루지 않습니다.

단일 속성에서 먼저 데이터의 format을 정의합니다. GPU에서 인식할 수 있는 각 꼭짓점 데이터의 유형을 설명하는 GPUVertexFormat 유형 목록에서 가져온 값입니다. 꼭짓점에는 각각 2개의 32비트 부동 소수점이 있으므로 float32x2 형식을 사용합니다. 예를 들어 꼭짓점 데이터가 각각 4개의 부호 없는 16비트 정수로 구성된 경우, uint16x4를 대신 사용합니다. 패턴이 보이나요?

다음으로 offset은 이 특정 속성이 시작하는 꼭짓점에 포함된 바이트 수를 설명합니다. 버퍼에 속성이 두 개 이상 있을 때만 이 문제를 걱정하면 되며 이 Codelab에서는 표시되지 않습니다.

마지막으로 shaderLocation이 있습니다. 0에서 15 사이의 숫자여야 하며 정의한 모든 속성에 대해 고유해야 합니다. 이 속성은 꼭짓점 셰이더의 특정 입력에 연결하며 이에 관해서는 다음 섹션에서 알아봅니다.

지금 이러한 값을 정의하더라도 실제로 이 값을 WebGPU API에 실제로 전달하지는 않습니다. 이제 이러한 작업이 진행되겠지만 꼭짓점을 정의할 시점의 이러한 값을 고려하는 것이 가장 쉬우므로 나중에 사용할 수 있도록 지금 설정합니다.

셰이더로 시작하기

이제 렌더링할 데이터가 준비되었지만 이를 어떻게 처리할지 정확히 GPU에 알려야 합니다. 이러한 문제의 대부분은 셰이더에서 발생합니다.

셰이더는 개발자가 작성하고 GPU에서 실행하는 소규모 프로그램입니다. 각 셰이더는 꼭짓점 처리, 프래그먼트 처리, 일반 컴퓨팅 등 서로 다른 데이터 스테이지에서 작동합니다. GPU를 기반으로 하기 때문에 평균 JavaScript보다 더욱 체계적으로 구성됩니다. 그러나 이러한 구조는 매우 빠르게, 무엇보다도 동시에 실행할 수 있게 해줍니다.

WebGPU의 셰이더는 WGSL(WebGPU Shading Language)이라는 셰이딩 언어로 작성됩니다. WGSL은 구문적으로는 Rust와 비슷하지만, 일반적인 유형의 GPU 작업(예: 벡터 및 행렬 수학)을 더 쉽고 빠르게 만드는 기능을 갖추고 있습니다. 셰이딩 언어 전체를 가르치는 것은 이 Codelab의 범위에는 포함되지 않지만 몇 가지 간단한 예시를 살펴보는 동안 몇 가지 기본사항을 학습할 수 있기를 바랍니다.

셰이더 자체는 WebGPU에 문자열로 전달됩니다.

  • 다음을 vertexBufferLayout 아래의 코드에 복사하여 셰이더 코드를 입력할 위치를 만듭니다.

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

선택사항인 label 및 WGSL code를 문자열로 제공하는 device.createShaderModule()를 호출하여 셰이더를 만듭니다. (여기서 백틱을 사용하여 여러 줄 문자열을 사용할 수 있습니다.) 유효한 WGSL 코드를 추가하면 함수가 컴파일된 결과와 함께 GPUShaderModule 객체를 반환합니다.

꼭짓점 셰이더 정의

GPU도 꼭짓점 셰이더에서 시작되니 꼭짓점 셰이더부터 시작하겠습니다.

꼭짓점 셰이더는 함수로 정의되며 GPU는 vertexBuffer의 꼭짓점마다 이 함수를 한 번씩 호출합니다. vertexBuffer에는 6개의 위치(꼭짓점)가 있으므로 정의한 함수는 6번 호출됩니다. 호출될 때마다 vertexBuffer의 다른 위치가 함수에 인수로 전달되며 꼭짓점 셰이더 함수의 기능이 클립 공간에 해당하는 위치를 반환합니다.

순차적으로 호출되지 않을 수도 있다는 점을 이해해야 합니다. 대신 GPU는 이와 같은 셰이더를 동시에 실행하여 수백 또는 수천 개의 꼭짓점을 동시에 처리하는 데 뛰어납니다. 이는 GPU의 놀라운 속도를 좌우하는 중요한 부분이지만 몇 가지 제한사항이 있습니다. 극단적인 동시 실행을 보장하기 위해 꼭짓점 셰이더는 서로 통신할 수 없습니다. 각 셰이더 호출은 한 번에 하나의 꼭짓점에 대한 데이터만 수 있으며 단일 꼭짓점에 대한 값만 출력할 수 있습니다.

WGSL에서는 꼭짓점 셰이더 함수의 이름을 원하는 대로 지정할 수 있지만 함수가 나타내는 셰이더 단계를 표시하려면 앞에 @vertex 속성이 있어야 합니다. WGSL은 fn 키워드로 함수를 나타내고, 괄호를 사용하여 인수를 선언하고, 중괄호를 사용하여 범위를 정의합니다.

  1. 다음과 같이 빈 @vertex 함수를 만듭니다.

index.html(createShaderModule 코드)

@vertex
fn vertexMain() {

}

하지만 꼭짓점 셰이더는 최소한 클립 공간에서 처리되는 꼭짓점의 최종 위치를 반환해야 하므로 유효하지 않습니다. 이는 항상 4차원 벡터로 제공됩니다. 벡터는 셰이더에서 흔히 사용되므로 4차원 벡터의 경우 vec4f와 같은 자체 유형으로 언어에서 최고의 프리미티브로 처리됩니다. 2D 벡터(vec2f)와 3D 벡터(vec3f)에도 비슷한 유형이 있습니다.

  1. 반환되는 값이 필수 위치임을 나타내려면 @builtin(position) 속성으로 표시합니다. -> 기호를 사용하여 함수가 반환함을 나타냅니다.

index.html(createShaderModule 코드)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

물론 함수에 반환 유형이 있으면 실제로 함수 본문의 값을 반환해야 합니다. vec4f(x, y, z, w) 구문을 사용하여 반환할 새 vec4f를 생성할 수 있습니다. x, y, z 값은 모두 부동 소수점 수로, 반환 값에서 클립 공간의 꼭짓점 위치를 나타냅니다.

  1. 정적 값 (0, 0, 0, 1)을 반환하면 기술적으로 유효한 꼭짓점 셰이더가 있습니다. 다만 GPU가 생성하는 삼각형이 한 점일 뿐이라는 것을 인식하여 삭제하기 때문에 아무것도 표시하지 않습니다.

index.html(createShaderModule 코드)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

대신 원하는 것은 개발자가 만든 버퍼의 데이터를 활용하는 것이고, 이를 위해 @location() 속성을 사용하여 함수의 인수를 선언하고 vertexBufferLayout에서 설명한 유형과 일치하는 유형을 사용합니다. 0shaderLocation을 지정했으므로 WGSL 코드에서 인수를 @location(0)로 표시합니다. 또한 형식을 2D 벡터인 float32x2로 정의했으므로 WGSL에서 인수는 vec2f입니다. 원하는 대로 이름을 지정할 수 있지만 꼭짓점 위치를 나타내므로 pos와 같은 이름은 자연스러운 것처럼 보입니다.

  1. 셰이더 함수를 다음 코드로 변경합니다.

index.html(createShaderModule 코드)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

이제 이 위치를 반환해야 합니다. 위치는 2D 벡터이고 반환 유형은 4D 벡터이므로 약간 변경해야 합니다. 그러려면 위치 인수에서 두 구성요소를 가져와 반환 벡터의 처음 두 구성요소에 배치하고 마지막 두 구성요소는 각각 01로 둡니다.

  1. 사용할 위치 구성요소를 명시적으로 표시하여 올바른 위치를 반환합니다.

index.html(createShaderModule 코드)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

그러나 이러한 종류의 매핑은 셰이더에서 매우 일반적이기 때문에 편리한 약어로 위치 벡터를 첫 번째 인수로 전달할 수 있으며 이는 동일한 의미입니다.

  1. 다음 코드를 사용하여 return 문을 다시 작성합니다.

index.html(createShaderModule 코드)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

이것이 초기 꼭짓점 셰이더입니다. 매우 간단하게 적용할 수 있으며 위치는 사실상 변경되지 않지만 시작하기에는 충분합니다.

프래그먼트 셰이더 정의

다음은 프래그먼트 셰이더입니다. 프래그먼트 셰이더는 꼭짓점 셰이더와 매우 유사한 방식으로 작동하지만, 모든 꼭짓점에 대해 호출되는 대신 그려진 모든 픽셀에 대해 호출됩니다.

프래그먼트 셰이더는 항상 꼭짓점 셰이더 다음에 호출됩니다. GPU가 꼭짓점 셰이더의 출력을 가져와 삼각 측량하여 세 점 집합에서 삼각형을 만듭니다. 그런 다음, 이러한 삼각형에 어떤 출력 색상 첨부파일이 포함되어 있는지 찾아 이러한 각 삼각형을 래스터화한 다음, 각 픽셀에 프래그먼트 셰이더를 한 번 호출합니다. 프래그먼트 셰이더는 일반적으로 꼭짓점 셰이더에서 전송된 값과 GPU가 색상 첨부파일에 쓰는 텍스처와 같은 자산에서 계산된 색상을 반환합니다.

꼭짓점 셰이더와 마찬가지로 프래그먼트 셰이더는 대규모 병렬 방식으로 실행됩니다. 입력과 출력 면에서 꼭짓점 셰이더보다 조금 더 유연하지만, 각 삼각형의 각 픽셀에 하나의 색상을 반환하는 것으로 고려할 수 있습니다.

WGSL 프래그먼트 셰이더 함수는 @fragment 속성으로 표시되며 vec4f도 반환합니다. 그러나 이 경우 벡터는 위치가 아니라 색상을 나타냅니다. 반환된 색상에 쓰이는 beginRenderPass 호출에서 colorAttachment를 나타내려면 반환 값에 @location 속성을 제공해야 합니다. 첨부파일이 한 개만 있으므로 위치는 0입니다.

  1. 다음과 같이 빈 @fragment 함수를 만듭니다.

index.html(createShaderModule 코드)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

반환된 벡터의 네 가지 구성요소는 빨간색, 녹색, 파란색, 알파 색상 값입니다. 이 값은 앞에서 beginRenderPass에서 설정한 clearValue와 정확히 동일한 방식으로 해석됩니다. vec4f(1, 0, 0, 1)가 밝은 빨간색이므로 정사각형 색상이 적절해 보입니다. 원하는 색상으로 자유롭게 설정할 수 있습니다.

  1. 다음과 같이 반환된 색상 벡터를 설정합니다.

index.html(createShaderModule 코드)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

완전한 프래그먼트 셰이더입니다. 별로 흥미롭지 않습니다. 모든 삼각형의 모든 픽셀을 빨간색으로 설정하기만 하면 됩니다.

요약하자면 위에서 설명한 셰이더 코드를 추가한 후의 createShaderModule 호출은 이제 다음과 같습니다.

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

    @fragment
    fn fragmentMain() -> @location(0) vec4f {
      return vec4f(1, 0, 0, 1);
    }
  `
});

렌더링 파이프라인 만들기

셰이더 모듈은 자체적으로 렌더링하는 데 사용할 수 없습니다. 대신 device.createRenderPipeline()을 호출하여 만든 GPURenderPipeline의 일부로 사용해야 합니다. 렌더링 파이프라인은 사용되는 셰이더, 꼭짓점 버퍼의 데이터를 해석하는 방법, 렌더링해야 하는 도형(선, 점, 삼각형 등)의 종류 등과 같은 도형의 그리기 방식을 제어합니다.

렌더링 파이프라인은 전체 API에서 가장 복잡한 객체이지만 걱정하지 마세요. 전달할 수 있는 값은 대부분 선택사항이며, 몇 개만 제공하면 시작할 수 있습니다.

  • 다음과 같이 렌더링 파이프라인을 만듭니다.

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: "auto",
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

모든 파이프라인에는 파이프라인에 필요한 입력 유형(꼭짓점 버퍼 제외)을 설명하는 layout이 필요하지만 실제로는 없습니다. 다행히 지금은 "auto"를 전달할 수 있으며 파이프라인은 셰이더에서 자체 레이아웃을 빌드합니다.

다음으로 vertex 단계에 관한 세부정보를 제공해야 합니다. module은 꼭짓점 셰이더가 포함된 GPUShaderModule이고, entryPoint는 모든 꼭짓점 호출에 대해 호출되는 셰이더 코드의 함수 이름을 제공합니다. (단일 셰이더 모듈에 @vertex@fragment 함수를 여러 개 포함할 수 있습니다.) 버퍼는 이 파이프라인을 사용하는 꼭짓점 버퍼에 데이터가 패키징되는 방식을 설명하는 GPUVertexBufferLayout 객체의 배열입니다. 다행히 앞서 vertexBufferLayout에 이미 정의되어 있습니다. 여기에서 데이터를 전달합니다.

마지막으로 fragment 단계에 관한 세부정보가 있습니다. 여기에는 꼭짓점 단계와 같은 셰이더 모듈entryPoint도 포함됩니다. 마지막 부분은 이 파이프라인이 사용되는 targets를 정의하는 것입니다. 이는 파이프라인이 출력되는 색상 첨부파일의 세부정보(예: format 텍스처)를 제공하는 사전의 배열입니다. 이러한 세부정보는 이 파이프라인이 사용되는 렌더 패스의 colorAttachments에 지정된 텍스처와 일치해야 합니다. 렌더 패스는 캔버스 컨텍스트의 텍스처를 사용하고 canvasFormat 형식으로 저장한 값을 형식에 사용하므로 여기에서 동일한 형식을 전달합니다.

렌더링 파이프라인을 만들 때 지정할 수 있는 모든 옵션에 가깝지는 않지만 이 Codelab의 요구사항을 충족하기에는 충분합니다.

정사각형 그리기

이제 정사각형을 그리는 데 필요한 모든 것을 갖추었습니다.

  1. 정사각형을 그리려면 encoder.beginRenderPass()pass.end() 호출 쌍으로 다시 돌아가서 그 사이에 다음 명령어를 추가합니다.

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

그러면 정사각형을 그리는 데 필요한 모든 정보가 WebGPU에 제공됩니다. 먼저 setPipeline()을 사용하여 그리는 데 사용해야 하는 파이프라인을 나타냅니다. 여기에는 사용되는 셰이더, 꼭짓점 데이터의 레이아웃 및 기타 관련 상태 데이터가 포함됩니다.

그런 다음 정사각형의 꼭짓점이 있는 버퍼를 사용하여 setVertexBuffer()를 호출합니다. 이 버퍼는 현재 파이프라인의 vertex.buffers 정의에 있는 0번째 요소에 해당하므로 0로 호출합니다.

마지막으로 draw()를 호출합니다. 이 작업은 이전에 수행된 모든 설정을 생각해보면 이상할 정도로 간단해 보입니다. 전달해야 하는 것은 렌더링해야 하는 꼭짓점 수입니다. 이 개수는 현재 설정된 꼭짓점 버퍼에서 가져와 현재 설정된 파이프라인으로 해석합니다. 단순히 6으로 하드 코딩할 수 있지만 꼭짓점 배열에서 계산하면(부동 소수점 수 12개/꼭짓점당 좌표 2개 = 6개 꼭짓점) 예를 들어 정사각형을 원으로 교체하기로 결정한 경우 수작업으로 업데이트할 일이 줄어듭니다.

  1. 화면을 새로 고치고 열심히 노력한 결과를 살펴 보세요. 색이 있는 큰 정사각형이 하나 있습니다.

WebGPU로 렌더링된 빨간색 단일 정사각형

5. 그리드 그리기

먼저 잠시 시간을 내어 자신을 축하해 주세요. 대부분의 GPU API에서 화면에 첫 번째 도형을 가져오는 것은 가장 어려운 단계 중 하나입니다. 여기에서 하는 모든 작업은 더 적은 단계로 완료할 수 있으므로 진행 상황을 간편하게 확인할 수 있습니다.

이 섹션에서는 다음을 학습합니다.

  • JavaScript에서 변수(유니폼)를 셰이더에 전달하는 방법
  • 유니폼을 사용하여 렌더링 동작을 변경하는 방법
  • 인스턴싱을 사용하여 동일한 도형의 여러 변형을 그리는 방법

그리드 정의

그리드를 렌더링하려면 그리드에 관한 매우 기본적인 정보를 알아야 합니다. 너비와 높이 모두 몇 개의 셀을 포함하고 있나요? 이는 개발자의 몫이지만 좀 더 쉽게 유지하려면 그리드를 정사각형(너비와 높이가 같음)으로 처리하고 2의 거듭제곱인 크기를 사용합니다. (이렇게 하면 나중에 쉽게 계산할 수도 있습니다.) 결국에는 더 크게 만들겠지만, 이 섹션의 나머지 부분에서는 그리드 크기를 4x4로 설정하세요. 그러면 이 섹션에서 사용된 수학을 더 쉽게 보여줄 수 있습니다. 나중에 크기를 키워보세요.

  • JavaScript 코드 상단에 상수를 추가하여 그리드 크기를 정의합니다.

index.html

const GRID_SIZE = 4;

다음으로, 캔버스에서 GRID_SIZE x GRID_SIZE를 맞출 수 있도록 정사각형을 렌더링하는 방법을 업데이트해야 합니다. 즉, 정사각형은 훨씬 작아야 하고 여러 개가 있어야 합니다.

이제 여기에 접근할 수 있는 한 가지 방법은 꼭짓점 버퍼를 훨씬 더 크게 만들고 내부의 적절한 위치에 GRID_SIZE x GRID_SIZE 크기의 정사각형을 정의하는 것입니다. 이를 위한 코드는 그렇게 어렵지 않을 것입니다. 몇 개의 루프로 약간의 계산만 하면 됩니다. 그렇다고 해서 GPU를 최대한 활용하고 효과를 달성하는 데 필요한 것보다 많은 메모리를 사용하는 것도 아닙니다. 이 섹션에서는 더욱 GPU 친화적인 접근 방식을 살펴봅니다.

균일 버퍼 만들기

먼저 선택한 그리드 크기를 셰이더에 전달해야 합니다. 셰이더가 이 크기를 사용하여 사물이 표시되는 방식을 변경하기 때문입니다. 크기를 셰이더에 하드 코딩할 수 있지만, 그러면 그리드 크기를 변경할 때마다 셰이더와 렌더링 파이프라인을 다시 만들어야 하는데, 이는 많은 비용이 듭니다. 더 나은 방법은 그리드 크기를 유니폼으로 셰이더에 제공하는 것입니다.

앞에서 살펴봤듯이 꼭짓점 버퍼의 다른 값이 꼭짓점 셰이더의 모든 호출에 전달됩니다. 유니폼은 모든 호출에서 동일한 버퍼의 값입니다. 도형(예: 위치), 전체 애니메이션 프레임(예: 현재 시간) 또는 앱의 전체 수명(예: 사용자 환경설정)에 공통된 값을 전달하는 데 유용합니다.

  • 다음 코드를 추가하여 균일 버퍼를 만듭니다.

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

앞에서 꼭짓점 버퍼를 만들 때 사용한 것과 거의 동일한 코드이기 때문에 매우 익숙할 것입니다. 유니폼이 꼭짓점과 동일한 GPUBuffer 객체를 통해 WebGPU API와 통신하기 때문입니다. 주요 차이점은 이번에는 usageGPUBufferUsage.VERTEX 대신 GPUBufferUsage.UNIFORM이 포함되어 있다는 점입니다.

셰이더에서 유니폼에 액세스

  • 다음 코드를 추가하여 유니폼을 정의합니다.

index.html(createShaderModule 호출)

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos / grid, 0, 1);
}

// ...fragmentMain is unchanged 

이 코드는 셰이더에서 grid라고 하는 유니폼을 정의합니다. 이 유니폼은 방금 균일 버퍼에 복사한 배열과 일치하는 2D 부동 벡터입니다. 또한 유니폼이 @group(0)@binding(0)에 바인딩되도록 지정합니다. 이러한 값의 의미는 곧 배우게 됩니다.

그런 다음 셰이더 코드의 다른 위치에서 필요에 따라 그리드 벡터를 사용할 수 있습니다. 이 코드에서는 꼭짓점 위치를 그리드 벡터로 나눕니다. pos는 2D 벡터이고 grid는 2D 벡터이므로 WGSL은 구성요소별 분할을 실행합니다. 즉, 결과는 vec2f(pos.x / grid.x, pos.y / grid.y)와 동일합니다.

이러한 유형의 벡터 작업은 GPU 셰이더에서 매우 일반적입니다. 많은 렌더링 및 컴퓨팅 기법이 GPU 셰이더에 의존하기 때문입니다.

이 경우 그리드 크기 4를 사용하면 렌더링하는 정사각형은 원래 크기의 1/4이 됩니다. 이 중 4개를 행 또는 열에 맞추려는 경우에 적합합니다.

바인드 그룹 만들기

그러나 셰이더에서 유니폼을 선언하더라도 개발자가 만든 버퍼와 연결되지는 않습니다. 이렇게 하려면 바인드 그룹을 만들고 설정해야 합니다.

바인드 그룹은 셰이더에 동시에 액세스할 수 있게 하려는 리소스의 모음입니다. 균일 버퍼와 같은 여러 유형의 버퍼와 여기에서 다루지는 않지만 WebGPU 렌더링 기술의 일반적인 부분인 텍스처, 샘플러와 같은 기타 리소스가 포함될 수 있습니다.

  • 균일 버퍼 및 렌더링 파이프라인을 만든 후 다음 코드를 추가하여 균일 버퍼로 바인드 그룹을 만듭니다.

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  }],
});

현재 표준 label 외에도 이 바인드 그룹에 포함된 리소스 유형을 설명하는 layout도 필요합니다. 이 내용은 이후 단계에서 자세히 살펴보겠지만 layout: "auto"로 파이프라인을 만들었으므로 현재는 파이프라인에 바인드 그룹 레이아웃을 요청할 수 있습니다. 이렇게 하면 파이프라인이 셰이더 코드 자체에서 선언한 바인딩에서 바인드 그룹 레이아웃을 자동으로 만듭니다. 이 경우에는 getBindGroupLayout(0)에 요청합니다. 여기서 0는 셰이더에 입력한 @group(0)에 해당합니다.

레이아웃을 지정한 후에는 entries 배열을 제공해야 합니다. 각 항목은 최소 다음 값을 포함하는 사전입니다.

  • binding: 셰이더에 입력한 @binding() 값에 해당합니다. 이 경우는 0입니다.
  • resource: 지정된 바인딩 색인의 변수에 노출하려는 실제 리소스입니다. 이 경우 균일 버퍼입니다.

이 함수는 변경 불가능한 불투명 핸들인 GPUBindGroup을 반환합니다. 바인드 그룹이 생성된 후에는 바인드 그룹이 가리키는 리소스를 변경할 수 없습니다. 하지만 해당 리소스의 콘텐츠는 변경할 수 있습니다. 예를 들어 새 그리드 크기를 포함하도록 균일 버퍼를 변경하면 이 바인드 그룹을 사용하는 향후 그리기 호출에 반영됩니다.

바인드 그룹 바인딩

이제 바인드 그룹이 생성되었으므로 WebGPU에 그릴 때 이 그룹을 사용하도록 지시해야 합니다. 다행히 이 과정은 매우 간단합니다.

  1. 렌더 패스로 돌아가서 draw() 메서드 앞에 다음 새 줄을 추가합니다.

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

첫 번째 인수로 전달된 0는 셰이더 코드의 @group(0)에 해당합니다. @group(0)의 일부인 각 @binding이 이 바인드 그룹의 리소스를 사용한다는 의미입니다.

이제 균일 버퍼가 셰이더에 노출됩니다.

  1. 페이지를 새로고침하면 다음과 같이 표시됩니다.

밝은 파란색 배경의 중앙에 있는 작은 빨간색 정사각형

축하합니다. 이제 정사각형이 이전 크기의 1/4을 차지했습니다. 그다지 크지는 않지만 유니폼이 실제로 적용되고 셰이더가 그리드 크기에 액세스할 수 있음을 보여줍니다.

셰이더에서 도형 조작

이제 셰이더에서 그리드 크기를 참조할 수 있으므로 원하는 그리드 패턴에 맞게 렌더링하는 도형을 조작하기 위한 작업을 시작할 수 있습니다. 이를 위해 달성하려는 목표를 정확히 고려해야 합니다.

캔버스를 개별 셀로 개념적으로 분할해야 합니다. 오른쪽으로 이동할 때 X축이 증가하고 위로 이동할 때 Y축이 증가하는 규칙을 유지하려면 첫 번째 셀이 캔버스의 왼쪽 하단에 있도록 해야 합니다. 그러면 현재 정사각형 도형이 중앙에 있는 다음과 같은 레이아웃이 표시됩니다.

현재 중앙에 렌더링된 정사각형 도형이 있는 각 셀을 시각화할 때 정규화된 기기 좌표 공간을 나누는 그리드 개념을 보여주는 이미지

이번 과제에서는 셰이더에서 셀 좌표를 고려하여 셀 내에 있는 정사각형 도형을 배치할 수 있는 방법을 찾아야 합니다.

먼저, 정사각형이 캔버스의 중심을 둘러싸도록 정의되었기 때문에 어떤 셀과도 잘 정렬되지 않은 것을 볼 수 있습니다. 정사각형을 셀의 반만큼 이동시켜 셀 안에 잘 정렬되도록 합니다.

이 문제를 해결할 수 있는 한 가지 방법은 정사각형의 꼭짓점 버퍼를 업데이트하는 것입니다. 예를 들어 오른쪽 아래 모서리가 (-0.8, -0.8)이 아닌 (0.1, 0.1)이 되도록 꼭짓점을 이동시키면 이 정사각형이 이동하면서 셀 경계에 맞게 잘 배치됩니다. 그러나 셰이더에서 꼭짓점이 처리되는 방식을 완전히 제어할 수 있으므로 셰이더 코드를 사용하여 꼭짓점을 간단하게 옮길 수 있습니다.

  1. 꼭짓점 셰이더 모듈을 다음 코드로 변경합니다.

index.html(createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Add 1 to the position before dividing by the grid size.
  let gridPos = (pos + 1) / grid;

  return vec4f(gridPos, 0, 1);
}

이렇게 하면 그리드 크기로 나누기 전에 모든 꼭짓점이 위쪽과 오른쪽으로 1씩(클립 공간의 절반임) 이동합니다. 그 결과 원점 바로 옆 그리드에 잘 정렬된 정사각형이 됩니다.

셀 (2, 2)에 빨간색 정사각형이 있는 4x4 그리드로 개념적으로 분할된 캔버스의 시각화

다음으로, 캔버스의 좌표계는 (0, 0)을 중앙에 배치하고 (-1, -1)을 왼쪽 아래에 배치하기 때문에 (0, 0)을 왼쪽 아래에 배치하려면 그리드 크기로 나눈 도형의 위치를 (-1, -1)만큼 이동해야 합니다.

  1. 도형의 위치를 다음과 같이 변환합니다.

index.html(createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Subtract 1 after dividing by the grid size.
  let gridPos = (pos + 1) / grid - 1;

  return vec4f(gridPos, 0, 1); 
}

이제 정사각형이 셀 (0, 0)에 깔끔하게 배열됩니다.

셀 (0, 0)에 빨간색 정사각형이 있는 4x4 그리드로 개념적으로 분할된 캔버스의 시각화

다른 셀에 배치하려면 어떻게 해야 할까요? 셰이더에 cell 벡터를 선언하고 let cell = vec2f(1, 1)와 같은 정적 값으로 채워 확인할 수 있습니다.

이를 gridPos에 추가하면 알고리즘에서 - 1이 실행취소되므로 원하는 작업이 아닙니다. 대신 각 셀마다 그리드 단위 1(캔버스의 1/4)만큼만 정사각형을 이동합니다. grid로 다시 나눠야 할 것 같습니다.

  1. 다음과 같이 그리드 위치를 변경합니다.

index.html(createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

지금 새로고침하면 다음과 같이 표시됩니다.

셀 (0, 0), 셀 (0, 1), 셀 (1, 0), 셀 (1, 1) 사이에 빨간색 정사각형이 있는 4x4 그리드로 개념적으로 분할된 캔버스의 시각화

이건 원하는 결과가 아니네요.

캔버스 좌표가 -1에서 +1로 변하기 때문에 실제로는 총 2단위가 됩니다. 즉, 꼭짓점을 캔버스의 1/4 위치로 이동하려면 0.5 단위로 이동해야 합니다. GPU 좌표로 추론할 때 실수하기 쉽습니다. 다행히 문제를 해결하는 방법은 간단합니다.

  1. 다음과 같이 오프셋에 2를 곱합니다.

index.html(createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

이렇게 하면 정확하게 원하는 결과를 얻을 수 있습니다.

셀 (1, 1)에 빨간색 정사각형이 있는 4x4 그리드로 개념적으로 분할된 캔버스의 시각화

스크린샷은 다음과 같습니다.

진한 파란색 배경에 빨간색 정사각형이 있는 스크린샷 이전 다이어그램에서 설명한 것과 같은 위치에 빨간색 정사각형이 그리드 오버레이가 없는 상태로 그려져 있습니다.

또한 이제 cell을 그리드 경계 내의 값으로 설정한 다음 새로고침하여 원하는 위치에서 정사각형 렌더링을 확인할 수 있습니다.

인스턴스 그리기

이제 계산을 통해 정사각형을 원하는 위치에 배치할 수 있습니다. 다음 단계는 그리드의 각 셀에 정사각형 하나를 렌더링하는 것입니다.

셀 좌표를 균일 버퍼에 작성한 다음 그리드의 각 정사각형에 대해 한 번씩 draw를 호출하여 매번 유니폼을 업데이트하는 것입니다. 하지만 GPU는 매번 JavaScript로 새 좌표가 작성될 때까지 기다려야 하므로 이 작업은 매우 느립니다. GPU에서 좋은 성능을 얻기 위한 비결 중 하나는 시스템의 다른 부분을 기다리는 데 소요되는 시간을 최소화하는 것입니다.

대신 인스턴싱이라는 방법을 사용할 수 있습니다. 인스턴싱은 GPU에 한 번의 draw 호출로 동일한 도형의 여러 사본을 그리도록 하는 방법입니다. 이는 매 사본마다 한 번씩 draw를 호출하는 것보다 훨씬 빠릅니다. 도형의 각 사본을 인스턴스라고 합니다.

  1. GPU에 그리드를 채우기에 충분한 수의 사각형 인스턴스 수를 GPU에 알리려면 기존 그리기 호출에 인수 하나를 추가합니다.

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

이렇게 하면 정사각형의 꼭짓점 6개(vertices.length / 2)를 16번(GRID_SIZE * GRID_SIZE) 그릴 수 있습니다. 하지만 페이지를 새로고침하면 여전히 다음과 같이 표시됩니다.

위의 다이어그램과 동일한 이미지로, 아무것도 변경되지 않았음을 나타냄.

왜일까요? 이는 정사각형이 16개 모두 같은 위치에 그려지기 때문입니다. 셰이더에 인스턴스별로 도형을 재배치하는 몇 가지 추가 로직이 있어야 합니다.

셰이더에서는 꼭짓점 버퍼에서 비롯된 pos와 같은 꼭짓점 속성 외에도 WGSL의 내장 값에 액세스할 수 있습니다. 이 값은 WebGPU에서 계산하며 이러한 값 중 하나는 instance_index입니다. instance_index는 셰이더 로직의 일부로 사용할 수 있는 0에서 number of instances - 1 사이의 부호 없는 32비트 숫자입니다. 동일한 인스턴스에 속하는 처리되는 꼭짓점마다 값이 동일합니다. 즉, 꼭짓점 셰이더는 꼭짓점 버퍼의 각 위치에 한 번씩 0instance_index로 6번 호출됩니다. 그런 다음 1instance_index로 6번 더, 2instance_index로 6번 더 반복합니다.

실제 동작을 확인하려면 셰이더 입력에 instance_index 내장 기능을 추가해야 합니다. 위치를 같은 방식으로 배치하되 @location 속성으로 태그를 지정하는 대신 @builtin(instance_index)를 사용하고 인수 이름을 원하는 대로 지정합니다. (예시 코드와 일치하도록 instance로 지정할 수 있습니다.) 그런 다음 셰이더 로직의 일부로 사용하세요.

  1. 셀 좌표 대신 instance를 사용합니다.

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
  
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

지금 새로고침하면 정사각형이 2개 이상 있음을 알 수 있습니다. 그러나 16개가 모두 표시되지는 않습니다.

진한 파란색 배경에 4개의 빨간색 정사각형이 왼쪽 하단 모서리에서 오른쪽 상단 모서리까지 대각선으로 배열된 스크린샷

생성한 셀 좌표가 (0, 0), (1, 1), (2, 2).... (15, 15) 중 첫 번째 4개만 캔버스에 맞기 때문입니다. 원하는 그리드를 만들려면 다음과 같이 각 색인이 그리드 내의 고유 셀에 매핑되도록 instance_index를 변환해야 합니다.

4x4 그리드로 개념적으로 분할된 캔버스의 시각화. 여기서 각 셀은 선형 인스턴스 색인에 해당합니다.

이를 계산하는 방법은 간단합니다. 각 셀의 X 값에는 instance_index모듈로와 그리드 너비가 필요하며 WGSL에서 % 연산자를 사용하면 됩니다. 또한 각 셀의 Y 값에 대해 instance_index를 그리드 너비로 나누고 남은 나머지는 삭제합니다. 이 작업은 WGSL의 floor() 함수를 사용하여 수행할 수 있습니다.

  1. 다음과 같이 계산을 변경합니다.

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

코드를 업데이트하면 마침내 오랫동안 기다리던 정사각형 그리드가 완성됩니다.

진한 파란색 배경에 빨간색 정사각형이 4개 열, 4개 행으로 배열된 스크린샷

  1. 이제 문제가 해결되었으니 뒤로 돌아가 그리드 크기를 최대로 늘립니다.

index.html

const GRID_SIZE = 32;

진한 파란색 배경에 빨간색 정사각형이 32개 열, 32개 행으로 배열된 스크린샷

짜잔! 이제 이 그리드를 아주 크게 만들 수 있으며, 평균 GPU의 처리도 좋습니다. GPU 성능 병목 현상이 발생하기 한참 전에 개별 정사각형이 표시되지 않습니다.

6. 추가 크레딧: 색상을 더 다채롭게 만드세요.

이제 이 Codelab의 나머지 부분을 위한 기초가 마련되었으므로 다음 섹션으로 쉽게 건너뛸 수 있습니다. 정사각형 그리드가 모두 같은 색상을 사용해도 괜찮지만, 그렇다면 지루하지 않을까요? 다행히 계산과 셰이더 코드를 조금 더 추가하면 좀 더 밝게 만들 수 있습니다.

셰이더에서 구조체 사용

지금까지는 꼭짓점 셰이더에서 변환된 위치라는 한 가지 데이터를 전달했습니다. 그러나 실제로 꼭짓점 셰이더에서 훨씬 더 많은 데이터를 반환한 후 프래그먼트 셰이더에서 사용할 수 있습니다.

꼭짓점 셰이더에서 데이터를 전달하는 유일한 방법은 데이터를 반환하는 것입니다. 위치를 반환하려면 꼭짓점 셰이더가 필요하므로 항상 다른 데이터를 함께 가져오려면 구조체에 꼭짓점 셰이더를 배치해야 합니다. WGSL의 구조체는 하나 이상의 명명된 속성을 포함하는 명명된 객체 유형입니다. 속성은 @builtin@location과 같은 속성으로 마크업할 수도 있습니다. 이를 함수 외부에 선언한 다음 필요에 따라 함수 안팎으로 인스턴스를 전달할 수 있습니다. 예를 들어 현재 꼭짓점 셰이더를 살펴보겠습니다.

index.html(createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> 
  @builtin(position) vec4f {

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • 함수 입력 및 출력에 구조체를 사용하여 동일한 항목을 표현합니다.

index.html(createShaderModule 호출)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

이를 위해서는 input으로 입력 위치와 인스턴스 색인을 참조해야 하며, 처음 반환하는 구조체를 변수로 선언하고 개별 속성을 설정해야 합니다. 이 경우 큰 차이는 없으며, 셰이더의 기능이 좀 더 길어지지만 셰이더가 더 복잡해짐에 따라 구조체를 사용하면 데이터를 정리하는 데 도움이 될 수 있습니다.

꼭짓점과 프래그먼트 함수 간 데이터 전달

참고로 @fragment 함수는 최대한 간단합니다.

index.html(createShaderModule 호출)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

어떠한 입력도 받지 않고 단색(빨간색)을 출력으로 전달합니다. 하지만 셰이더가 색이 있는 도형에 관해 더 잘 알고 있다면 이 추가 데이터를 사용하여 좀 더 흥미로운 결과를 얻을 수 있습니다. 예를 들어 각 정사각형의 색상을 셀 좌표를 기준으로 변경하려면 어떻게 해야 할까요? @vertex 단계는 렌더링 중인 셀을 알고 있으므로 @fragment 단계에 전달하기만 하면 됩니다.

꼭짓점 단계와 프래그먼트 단계 간에 데이터를 전달하려면 선택한 @location을 사용하여 출력 구조체에 데이터를 포함해야 합니다. 셀 좌표를 전달하려고 하므로 이전에 VertexOutput 구조체에 추가한 다음 반환하기 전에 @vertex 함수에서 설정합니다.

  1. 꼭짓점 셰이더의 반환 값을 다음과 같이 변경합니다.

index.html(createShaderModule 호출)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. @fragment 함수에서 동일한 @location이 있는 인수를 추가하여 값을 수신합니다. (이름이 일치하지 않아도 되지만, 일치하는 경우 추적이 더 용이합니다.)

index.html(createShaderModule 호출)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. 또는 구조체를 대신 사용할 수 있습니다.

index.html(createShaderModule 호출)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. 또 다른 대안**은** 코드에서 두 함수가 동일한 셰이더 모듈에 정의되어 있으므로 @vertex 단계의 출력 구조체를 재사용하는 것입니다. 이렇게 하면 이름과 위치가 자연스럽게 일관되게 되므로 값을 쉽게 전달할 수 있습니다.

index.html(createShaderModule 호출)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

선택한 패턴에 관계없이 @fragment 함수의 셀 번호에 액세스할 수 있고 이 함수를 사용하여 색상에 영향을 줄 수 있습니다. 위 코드를 사용하면 다음과 같이 출력됩니다.

가장 왼쪽 열이 초록색이고 맨 아래 행이 빨간색, 다른 모든 정사각형이 노란색인 정사각형 그리드

이제 확실히 색깔이 많지만 디자인이 좋아 보이지는 않습니다. 왼쪽 열과 아래쪽 행만 다른 이유가 궁금할 수 있습니다. @fragment 함수에서 반환하는 색상 값은 각 채널이 0~1 범위에 속해야 하고 이 범위를 벗어나는 값은 고정되기 때문입니다. 반면 셀 값의 축 범위는 0~32입니다. 여기서 볼 수 있듯이 첫 번째 행과 열은 빨간색 또는 녹색 채널에서 즉시 전체 값 1개에 도달하며, 이후 모든 셀은 동일한 값으로 고정됩니다.

색상 간에 더 매끄러운 전환을 원한다면 각 색상 채널에 대한 소수 값을 반환해야 합니다. 이상적으로는 0부터 시작하고 각 축을 따라 1로 끝나야 합니다. 즉, grid로 또 한 번 분할하는 것이 이상적입니다.

  1. 프래그먼트 셰이더를 다음과 같이 변경합니다.

index.html(createShaderModule 호출)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

페이지를 새로고침하면 새 코드가 전체 그리드에 걸쳐 훨씬 더 좋은 색상 그라데이션을 제공하는 것을 확인할 수 있습니다.

모서리가 검은색에서 빨간색, 녹색, 노란색으로 변하는 정사각형 그리드

확실히 개선된 것이지만 왼쪽 하단에 그리드가 검은색으로 변하는 어두운 모서리가 있습니다. Game of Life 시뮬레이션을 시작하면 그리드의 잘 보이지 않는 섹션이 진행 상황을 파악하기 어렵게 합니다. 다시 밝게 만드는 것이 좋습니다.

다행히 사용하지 않는 색상 채널(파란색)이 있습니다. 다른 색이 어두울 때는 파란색을 가장 밝게 하고, 다른 색은 강도의 증가에 따라 점점 희미해지는 것이 이상적입니다. 가장 쉬운 방법은 채널이 1부터 시작하도록 하고 셀 값 중 하나를 빼는 것입니다. c.x 또는 c.y이 될 수 있습니다. 둘 다 사용해 본 다음 원하는 방법을 선택하세요.

  1. 다음과 같이 프래그먼트 셰이더에 더 밝은 색상을 추가합니다.

createShaderModule 호출

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

결과가 매우 좋습니다.

모서리가 빨간색에서 녹색, 파란색, 노란색으로 변하는 정사각형 그리드

꼭 필요한 단계는 아닙니다. 그러나 더 좋아 보이므로 상응하는 체크포인트 소스 파일에 포함되어 있으며 이 Codelab의 나머지 스크린샷은 보다 다채로운 이 색상을 반영하는 스크린샷입니다.

7. 셀 상태 관리

다음으로는 GPU에 저장된 일부 상태에 따라 그리드에서 렌더링하는 셀을 제어해야 합니다. 이는 최종 시뮬레이션에서 중요합니다.

각 셀의 켜짐 신호만 있으면 거의 모든 값 유형을 저장할 수 있는 모든 옵션이 작동합니다. 균일 버퍼의 또 다른 사용 사례라고 생각할 수도 있습니다. 이 방법이 가능할 수는 있지만 균일 버퍼의 크기가 제한되고, 동적으로 크기가 지정된 배열을 지원할 수 없고(셰이더에서 배열 크기를 지정해야 함) 컴퓨팅 셰이더에 쓸 수 없기 때문에 더 어렵습니다. 마지막 항목이 가장 문제가 되는 이유는 컴퓨팅 셰이더의 GPU에서 게임 오브 라이프 시뮬레이션을 수행하려는 경우입니다.

다행히 이러한 제한을 모두 피하는 다른 버퍼 옵션이 있습니다.

스토리지 버퍼 만들기

스토리지 버퍼는 컴퓨팅 셰이더에서 읽고 쓸 수 있고 꼭짓점 셰이더에서 읽을 수 있는 범용 버퍼입니다. 매우 클 수 있으며 셰이더에 선언된 특정 크기가 필요하지 않으므로 일반 메모리와 훨씬 비슷합니다. 이 버퍼를 사용하여 셀 상태를 저장합니다.

  1. 셀 상태를 위한 스토리지 버퍼를 만들려면, 지금쯤이면 익숙해 보이는 버퍼 생성 코드의 스니펫을 사용합니다.

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

꼭짓점 및 균일 버퍼와 마찬가지로 적절한 크기로 device.createBuffer()를 호출한 후 이번에는 GPUBufferUsage.STORAGE를 사용하도록 지정해야 합니다.

동일한 크기의 TypedArray를 값으로 채운 다음 device.queue.writeBuffer()를 호출하여 이전과 같은 방식으로 버퍼를 채울 수 있습니다. 버퍼에 대한 그리드의 효과를 확인하려는 경우 먼저 버퍼를 예측 가능한 항목으로 채우는 것이 좋습니다.

  1. 다음 코드를 사용하여 모든 세 번째 셀을 활성화합니다.

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

셰이더에서 스토리지 버퍼 읽기

다음으로 그리드를 렌더링하기 전에 셰이더를 업데이트하여 스토리지 버퍼의 콘텐츠를 살펴봅니다. 이는 이전에 유니폼을 추가한 방법과 매우 유사합니다.

  1. 다음 코드를 사용하여 셰이더를 업데이트합니다.

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

먼저 그리드 유니폼 바로 아래에 바인딩 위치를 추가합니다. grid 유니폼과 동일한 @group을 유지하려고 하지만 @binding 수가 달라야 합니다. var 유형은 storage입니다. 다른 유형의 버퍼를 반영하기 위해 단일 벡터가 아닌 JavaScript에 Uint32Array를 일치시키기 위해 cellState에 제공하는 유형은 u32 값의 배열입니다.

다음으로 @vertex 함수의 본문에서 셀의 상태를 쿼리합니다. 상태는 스토리지 버퍼의 플랫 배열에 저장되므로 instance_index를 사용하여 현재 셀의 값을 조회할 수 있습니다.

상태가 비활성이라고 표시되면 셀을 어떻게 사용 중지하나요? 배열에서 가져오는 활성 및 비활성 상태는 1 또는 0이므로 활성 상태를 기준으로 도형을 조정할 수 있습니다. 1씩 조정하면 도형이 단독으로, 0으로 조정하면 도형이 단일 점으로 축소되고 GPU는 이를 삭제합니다.

  1. 셰이더 코드를 업데이트하여 셀의 활성 상태에 따라 위치를 조정합니다. WGSL의 유형 안전성 요구사항을 충족하려면 상태 값을 f32로 변환해야 합니다.

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

바인드 그룹에 스토리지 버퍼 추가

셀 상태가 적용되는지 확인하려면 스토리지 버퍼를 바인드 그룹에 추가하세요. 유니폼 버퍼와 동일한 @group의 일부이므로 JavaScript 코드의 동일한 바인드 그룹에도 추가해야 합니다.

  • 다음과 같이 스토리지 버퍼를 추가합니다.

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  },
  // New entry!
  {
    binding: 1,
    resource: { buffer: cellStateStorage }
  }],
});

새 항목의 binding이 셰이더에 있는 해당 값의 @binding()과 일치하는지 확인합니다.

준비가 되면 새로고침하고 패턴이 그리드에 표시되는 것을 볼 수 있습니다.

어두운 파란색 배경에 왼쪽 하단에서 오른쪽 상단으로 이어지는 다채로운 색상의 정사각형이 대각선으로 줄지어 있는 모습

핑퐁 버퍼 패턴 사용

빌드하고 있는 것과 유사한 대부분의 시뮬레이션은 일반적으로 상태 사본을 2개 이상 사용합니다. 시뮬레이션의 각 단계에서 사용자는 상태의 한 복사본에서 읽고 다른 상태에 씁니다. 그런 다음, 다음 단계에서 뒤집어서 이전에 작성한 상태에서 읽습니다. 일반적으로 최신 버전의 상태가 각 단계에서 상태 복사본 사이를 왔다 갔다 하기 때문에 핑퐁 패턴이라고 합니다.

이게 왜 필요할까요? 간단한 예를 살펴보겠습니다. 모든 활성 블록을 한 단계씩 오른쪽으로 이동하는 매우 간단한 시뮬레이션을 작성한다고 가정해 보겠습니다. 이해하기 쉽도록 JavaScript에서 데이터 및 시뮬레이션을 정의합니다.

// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];

function simulate() {
  for (let i = 0; i < state.length-1; ++i) {
    if (state[i] == 1) {
      state[i] = 0;
      state[i+1] = 1;
    }
  }
}

simulate(); // Run the simulation for one step.

그러나 이 코드를 실행하면 활성 셀이 한 번에 배열의 끝까지 이동합니다. 왜냐하면 상태를 제자리에서 계속 업데이트하기 때문입니다. 활성 셀을 오른쪽으로 이동한 후 다음 셀을 보면 활성 상태임을 알 수 있습니다. 다시 오른쪽으로 옮기는 것이 좋습니다. 관찰하면서 동시에 데이터를 변경하면 결과가 손상됩니다.

핑퐁 패턴을 사용하면 항상 마지막 단계의 결과 사용하여 시뮬레이션의 다음 단계를 실행합니다.

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA); 
  1. 동일한 버퍼를 두 개 만들기 위해 스토리지 버퍼 할당을 업데이트하여 자체 코드에서 이 패턴을 사용합니다.

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. 두 버퍼의 차이를 시각화하려면 서로 다른 데이터로 채워야 합니다.

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. 렌더링 시 다양한 스토리지 버퍼를 표시하려면 2개의 변형도 갖도록 바인드 그룹을 업데이트합니다.

index.html

const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
   device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }],
  })
];

렌더링 루프 설정

지금까지는 페이지 새로고침당 그리기가 한 번 이루어졌지만 이제는 시간 경과에 따라 데이터를 업데이트해 보겠습니다. 이를 위해서는 간단한 렌더링 루프가 필요합니다.

렌더링 루프는 특정 간격으로 콘텐츠를 캔버스에 그리는 끝없이 반복되는 루프입니다. 애니메이션을 원활하게 구현하려는 많은 게임과 기타 콘텐츠에서는 requestAnimationFrame() 함수를 사용하여 화면 새로고침 빈도(초당 60회)와 같은 속도로 콜백을 예약합니다.

이 앱도 이것을 사용할 수 있지만, 이 경우 시뮬레이션을 더 쉽게 수행할 수 있도록 더 긴 단계에 걸쳐 업데이트를 수행하는 것이 좋습니다. 시뮬레이션이 업데이트되는 속도를 제어할 수 있도록 루프를 직접 관리하세요.

  1. 먼저 시뮬레이션에서 업데이트할 속도를 선택하고(200ms가 좋지만 원하는 경우 더 느리거나 빠르게 설정할 수 있음), 완료된 시뮬레이션 단계 수를 추적합니다.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. 그런 다음 현재 렌더링에 사용하는 모든 코드를 새 함수로 이동합니다. setInterval()를 사용하여 원하는 간격으로 함수를 반복하도록 예약합니다. 함수에서 단계 수도 업데이트해야 하며 이를 사용하여 바인딩할 두 바인드 그룹 중 하나를 선택해야 합니다.

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count
  
  // Start a render pass 
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

이제 앱을 실행하면 캔버스가 생성한 두 상태 버퍼 표시 사이를 왔다 갔다 하는 것을 볼 수 있습니다.

어두운 파란색 배경에 왼쪽 하단에서 오른쪽 상단으로 이어지는 다채로운 색상의 정사각형이 대각선으로 줄지어 있는 모습 어두운 파란색 배경에 다채로운 정사각형이 세로로 줄지어 있는 모습

이것으로 렌더링 작업을 거의 완료했습니다. 드디어 컴퓨팅 셰이더 사용을 시작하는 다음 단계에서 빌드하는 Game of Life 시뮬레이션의 출력을 표시할 준비가 되었습니다.

여기서 살펴봤던 것들보다 WebGPU의 렌더링 기능이 훨씬 더 많은 것은 분명하지만 나머지는 이 Codelab의 범위를 벗어납니다. 이 Codelab이 WebGPU의 렌더링 작동 방식을 충분히 맛보고 3D 렌더링과 같은 고급 기술을 더 쉽게 이해하는 데 도움이 되기를 바랍니다.

8. 시뮬레이션 실행

이제 마지막으로 할 일은 컴퓨팅 셰이더에서 Game of Life 시뮬레이션을 진행하는 것입니다.

컴퓨팅 셰이더 사용

이 Codelab에서는 컴퓨팅 셰이더에 관해 추상적으로 살펴보았습니다. 그렇다면 컴퓨팅 셰이더란 정확히 무엇일까요?

컴퓨팅 셰이더는 GPU에서 극단적 동시 로드로 실행되도록 설계되었다는 점에서 꼭짓점 및 프래그먼트 셰이더와 유사하지만, 다른 두 셰이더 단계와는 달리 특정 입력 및 출력 집합이 없습니다. 스토리지 버퍼와 같이 선택한 소스에서만 데이터를 읽고 씁니다. 즉, 각 꼭짓점, 인스턴스 또는 픽셀에 한 번씩 실행하는 대신 원하는 셰이더 함수의 호출 개수를 알려야 합니다. 그런 다음 셰이더를 실행하면 어떤 호출을 처리하는지 알게 되며 액세스할 데이터와 거기에서 수행할 작업을 결정할 수 있습니다.

컴퓨팅 셰이더는 꼭짓점 및 프래그먼트 셰이더와 마찬가지로 셰이더 모듈에서 생성되어야 하므로 시작하려면 이를 코드에 추가하세요. 구현한 다른 셰이더의 구조를 고려할 때 컴퓨팅 셰이더의 기본 함수는 @compute 속성으로 표시해야 합니다.

  1. 다음 코드를 사용하여 컴퓨팅 셰이더를 만듭니다.

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

GPU는 3D 그래픽에 자주 사용되므로 컴퓨팅 셰이더는 X, Y, Z축을 따라 셰이더를 특정 횟수만큼 호출하도록 요청할 수 있도록 구성되어 있습니다. 이렇게 하면 2D 또는 3D 그리드를 준수하는 작업을 매우 쉽게 전달할 수 있으므로 사용 사례에 매우 유용합니다. 이 셰이더를 시뮬레이션의 각 셀에 대해 한 번씩 GRID_SIZE x GRID_SIZE배 호출하려고 합니다.

GPU 하드웨어 아키텍처의 특성상 이 그리드는 작업 그룹으로 나뉩니다. 작업 그룹에는 X, Y, Z의 크기가 있으며, 크기는 각각 1일 수 있지만 작업 그룹을 약간 더 크게 만들면 성능상의 이점이 있는 경우가 많습니다. 셰이더의 경우 8 x 8의 임의적인 작업 그룹 크기를 선택합니다. 이는 JavaScript 코드에서 추적하는 데 유용합니다.

  1. 다음과 같이 작업 그룹 크기의 상수를 정의합니다.

index.html

const WORKGROUP_SIZE = 8;

또한 정의한 상수를 쉽게 사용할 수 있도록 JavaScript의 템플릿 리터럴을 사용하여 작업 그룹 크기를 셰이더 함수 자체에 추가해야 합니다.

  1. 다음과 같이 셰이더 함수에 작업 그룹 크기를 추가합니다.

index.html(Compute createShaderModule 호출)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

이 함수를 사용한 작업이 (8 x 8 x 1) 그룹에서 완료되었음을 셰이더에 알립니다. (X축을 직접 지정해야 하지만 나머지는 모든 축의 기본값이 1입니다.)

다른 셰이더 단계와 마찬가지로, 컴퓨팅 셰이더 함수에 입력으로 받아들일 수 있는 다양한 @builtin 값이 있으므로 현재 진행 중인 호출을 알려주고 필요한 작업을 결정합니다.

  1. 다음과 같이 @builtin 값을 추가합니다.

index.html(Compute createShaderModule 호출)

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

셰이더 호출의 그리드 내 위치를 알려주는 부호 없는 정수로 구성된 3차원 벡터인 global_invocation_id 내장 함수를 전달합니다. 그리드의 각 셀에 대해 이 셰이더를 한 번씩 실행합니다. (0, 0, 0), (1, 0, 0), (1, 1, 0)... (31, 31, 0)과 같은 숫자를 얻습니다. 즉, 연산하려는 셀 색인으로 처리할 수 있습니다.

컴퓨팅 셰이더는 꼭짓점 및 프래그먼트 셰이더와 마찬가지로 사용하는 유니폼을 사용할 수도 있습니다.

  1. 다음과 같이 컴퓨팅 셰이더와 함께 유니폼을 사용하여 그리드 크기를 알려줍니다.

index.html(Compute createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f; // New line

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

꼭짓점 셰이더에서와 마찬가지로 셀 상태를 스토리지 버퍼로 노출해야 합니다. 하지만 이 경우에는 두 가지가 필요합니다. 컴퓨팅 셰이더에는 꼭짓점 위치나 프래그먼트 색상과 같은 필수 출력이 없으므로 스토리지 버퍼 또는 텍스처에 값을 쓰는 것이 컴퓨팅 셰이더에서 결과를 얻는 유일한 방법입니다. 앞서 살펴본 핑퐁 메서드를 사용합니다. 그리드의 현재 상태를 피드하는 스토리지 버퍼와 그리드의 새 상태를 작성하는 스토리지 버퍼가 있습니다.

  1. 다음과 같이 셀 입력 및 출력 상태를 스토리지 버퍼로 노출합니다.

index.html(Compute createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;
    
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

첫 번째 스토리지 버퍼는 var<storage>로 선언되어 읽기 전용으로 만들어지지만 두 번째 스토리지 버퍼는 var<storage, read_write>로 선언됩니다. 이렇게 하면 해당 버퍼를 컴퓨팅 셰이더의 출력으로 사용하여 버퍼를 읽고 쓸 수 있습니다. (WebGPU에는 쓰기 전용 스토리지 모드가 없습니다.)

다음으로 셀 색인을 선형 스토리지 배열에 매핑할 방법이 있어야 합니다. 이는 꼭짓점 셰이더에서 선형 instance_index를 가져와 2D 그리드 셀에 매핑한 것과 기본적으로 반대입니다. (참고로 알고리즘은 vec2f(i % grid.x, floor(i / grid.x))입니다.)

  1. 다른 방향으로 이동하는 함수를 작성합니다. 셀의 Y 값을 가져와 그리드 너비로 곱한 후 셀의 X 값을 더합니다.

index.html(Compute createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

// New function   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  
}

마지막으로, 작동하는지 확인하려면 셀이 현재 켜져 있으면 꺼지고 그 반대의 경우도 마찬가지인 아주 간단한 알고리즘을 구현해 보세요. 아직 Game of Life는 아니지만 컴퓨팅 셰이더가 작동하고 있음을 보여주기에 충분합니다.

  1. 다음과 같은 간단한 알고리즘을 추가합니다.

index.html(Compute createShaderModule 호출)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

지금까지 컴퓨팅 셰이더에 대해 살펴보았습니다. 그러나 결과를 보기 전에 몇 가지 사항을 추가로 변경해야 합니다.

바인드 그룹 및 파이프라인 레이아웃 사용

위의 셰이더에서 한 가지 주목할 점은 셰이더가 대부분 렌더링 파이프라인과 동일한 입력(유니폼 및 스토리지 버퍼)을 사용한다는 것입니다. 따라서 동일한 바인드 그룹을 간편하게 사용할 수 있다고 생각할 수도 있겠죠? 다행히도 그렇습니다. 그리고 그렇게 하려면 수작업으로 설정하는 게 조금 더 필요합니다.

바인드 그룹을 만들 때마다 GPUBindGroupLayout을 제공해야 합니다. 이전에는 렌더링 파이프라인에서 getBindGroupLayout()를 호출하여 이 레이아웃을 가져왔습니다. 이 레이아웃은 개발자가 생성 시 layout: "auto"를 제공했으므로 자동으로 생성되었습니다. 이 접근 방식은 하나의 파이프라인만 사용할 때 효과적이지만 리소스를 공유하려는 파이프라인이 여러 개 있는 경우 레이아웃을 명시적으로 만든 다음 바인드 그룹과 파이프라인 모두에 제공해야 합니다.

이유를 이해하려면 렌더링 파이프라인에서 단일 균일 버퍼와 단일 스토리지 버퍼를 사용하지만 방금 작성한 컴퓨팅 셰이더에는 두 번째 스토리지 버퍼가 필요합니다. 두 셰이더가 균일 버퍼와 첫 번째 스토리지 버퍼에 동일한 @binding 값을 사용하므로 파이프라인 간에 이 값을 공유할 수 있으며 렌더링 파이프라인은 사용하지 않는 두 번째 스토리지 버퍼를 무시합니다. 특정 파이프라인에서 사용하는 리소스뿐만 아니라 바인드 그룹에 있는 모든 리소스를 설명하는 레이아웃을 만들려고 합니다.

  1. 이 레이아웃을 만들려면 device.createBindGroupLayout()를 호출합니다.

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

이는 entries 목록을 설명한다는 점에서 바인드 그룹 자체를 생성하는 구조와 유사합니다. 차이점은 리소스 자체를 제공하기보다는 항목이 어떤 유형의 리소스여야 하는지, 어떻게 사용되는지 설명한다는 것입니다.

각 항목에서 리소스의 binding 번호를 지정합니다. 이는 바인드 그룹을 만들 때 살펴본 것처럼 셰이더의 @binding 값과 일치합니다. 리소스를 사용하는 셰이더 단계를 나타내는 GPUShaderStage 플래그인 visibility도 제공합니다. 꼭짓점과 컴퓨팅 셰이더에서 균일 버퍼와 첫 번째 스토리지 버퍼에 모두 액세스할 수 있어야 하지만 두 번째 스토리지 버퍼는 컴퓨팅 셰이더에서만 액세스할 수 있어야 합니다.

마지막으로, 사용 중인 리소스 유형을 지정합니다. 이는 다른 사전 키로, 노출해야 하는 항목에 따라 다릅니다. 여기서 세 가지 리소스는 모두 버퍼이므로 buffer 키를 사용하여 각각의 옵션을 정의합니다. texture 또는 sampler와 같은 다른 옵션도 있지만 여기서는 필요하지 않습니다.

버퍼 사전에서는 사용되는 type의 버퍼와 같은 옵션을 설정합니다. 기본값은 "uniform"이므로 바인딩 0의 사전을 비워둘 수 있습니다. (하지만 항목이 버퍼로 식별되도록 최소한 buffer: {}를 설정해야 합니다.) 바인딩 1은 셰이더에서 read_write 액세스와 함께 사용되지 않으므로 "read-only-storage" 유형이 지정되고, 바인딩 2는 read_write 액세스와 함께 사용되기 때문에 "storage" 유형이 지정됩니다.

bindGroupLayout이 생성되면 파이프라인에서 바인드 그룹을 쿼리하는 대신 바인드 그룹을 만들 때 이를 전달할 수 있습니다. 이렇게 하면 방금 정의한 레이아웃과 일치하도록 각 바인드 그룹에 새 스토리지 버퍼 항목을 추가해야 합니다.

  1. 다음과 같이 바인드 그룹 생성을 업데이트합니다.

index.html

// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: bindGroupLayout, // Updated Line
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[1] }
    }],
  }),
  device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: bindGroupLayout, // Updated Line

    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
];

이제 이 명시적 바인드 그룹 레이아웃을 사용하도록 바인드 그룹을 업데이트했으므로 동일한 것을 사용하도록 렌더링 파이프라인을 업데이트해야 합니다.

  1. GPUPipelineLayout을 만듭니다.

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

파이프라인 레이아웃은 하나 이상의 파이프라인에서 사용하는 바인드 그룹 레이아웃(여기서는 하나만 사용)의 목록입니다. 배열에서 바인드 그룹 레이아웃의 순서는 셰이더의 @group 속성과 일치해야 합니다. (즉, bindGroupLayout@group(0)와 연결됩니다.)

  1. 파이프라인 레이아웃이 준비되면 렌더링 파이프라인을 업데이트하여 "auto" 대신 사용합니다.

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

컴퓨팅 파이프라인 만들기

꼭짓점 및 프래그먼트 셰이더를 사용하려면 렌더링 파이프라인이 필요한 것처럼 컴퓨팅 셰이더를 사용하려면 컴퓨팅 파이프라인이 필요합니다. 다행히 컴퓨팅 파이프라인은 설정할 상태가 없고 셰이더와 레이아웃만 있기 때문에 렌더링 파이프라인보다 훨씬 덜 복잡합니다.

  • 다음 코드를 사용하여 컴퓨팅 파이프라인을 만듭니다.

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

업데이트된 렌더링 파이프라인과 마찬가지로 "auto" 대신 새 pipelineLayout을 전달하면 렌더링 파이프라인과 컴퓨팅 파이프라인이 모두 동일한 바인드 그룹을 사용할 수 있습니다.

컴퓨팅 패스

이를 통해 컴퓨팅 파이프라인을 실제로 활용할 수 있습니다. 렌더 패스에서 렌더링을 한다면 컴퓨팅 패스에서 컴퓨팅 작업을 해야 할 것으로 추측할 수 있습니다. 컴퓨팅과 렌더링 작업은 모두 동일한 명령어 인코더에서 발생할 수 있으므로 updateGrid 함수를 약간 셔플하는 것이 좋습니다.

  1. 인코더 생성을 함수 상단으로 옮긴 다음 이를 사용하여 컴퓨팅 패스를 시작합니다(step++ 이전).

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
  
// Start a render pass...

컴퓨팅 파이프라인과 마찬가지로 컴퓨팅 패스는 첨부파일을 신경 쓸 필요가 없기 때문에 렌더링 파이프라인보다 시작하기가 훨씬 간단합니다.

렌더 패스가 컴퓨팅 패스의 최신 결과를 즉시 사용할 수 있도록 하기 때문에 렌더 패스 전에 컴퓨팅 패스를 실행하려고 합니다. 이것이 컴퓨팅 파이프라인의 출력 버퍼가 렌더링 파이프라인의 입력 버퍼가 되도록 패스 간 step 수를 늘리는 이유이기도 합니다.

  1. 다음으로, 렌더링 패스와 마찬가지로 바인드 그룹 간 전환을 위해 동일한 패턴을 사용하여 컴퓨팅 패스 내에 파이프라인과 바인딩 그룹을 설정합니다.

index.html

const computePass = encoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. 마지막으로, 렌더 패스에서 그리는 대신 작업을 컴퓨팅 셰이더에 전달하여 각 축에서 실행하려는 작업 그룹 수를 알려줍니다.

index.html

const computePass = encoder.beginComputePass();

computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

여기서 매우 중요한 점dispatchWorkgroups()에 전달하는 숫자는 호출 수가 아니라는 것입니다. 대신 셰이더의 @workgroup_size에 정의된 대로 실행할 작업 그룹 수입니다.

전체 그리드를 포함하기 위해 셰이더가 32x32회 실행되도록 하고 작업 그룹 크기를 8x8로 하려면 4x4 작업 그룹을 전달해야 합니다(4 * 8 = 32). 따라서 그리드 크기를 작업 그룹 크기로 나누고 그 값을 dispatchWorkgroups()에 전달합니다.

이제 페이지를 다시 새로고침하면 업데이트할 때마다 그리드가 반전됩니다.

어두운 파란색 배경에 왼쪽 하단에서 오른쪽 상단으로 이어지는 다채로운 색상의 정사각형이 대각선으로 줄지어 있는 모습 어두운 파란색 배경에 왼쪽 하단에서 오른쪽 상단으로 이어지는 정사각형 2개 너비의 다채로운 사각형이 대각선으로 줄지어 있는 모습. 이전 이미지의 역전입니다.

Game of Life의 알고리즘 구현

최종 알고리즘을 구현하도록 컴퓨팅 셰이더를 업데이트하기 전에 스토리지 버퍼 콘텐츠를 초기화하는 코드로 돌아가서 각 페이지를 로드할 때마다 임의의 버퍼를 생성하도록 업데이트하는 것이 좋습니다. (일반적인 패턴으로는 Game of Life의 흥미로운 시작점이 될 수 없습니다.) 원하는 대로 값을 임의대로 지정할 수 있지만 적절한 결과를 얻을 수 있는 쉬운 방법이 있습니다.

  1. 임의의 상태에서 각 셀을 시작하려면 cellStateArray 초기화를 다음 코드로 업데이트합니다.

index.html

// Set each cell to a random state, then copy the JavaScript array 
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

이제 Game of Life 시뮬레이션을 위한 로직을 구현할 수 있습니다. 여기까지 오기 위해 온갖 애를 쓴 것에 비하면 셰이더 코드는 실망스러울 정도로 단순하게 느껴질 수 있습니다.

먼저 주어진 셀에 대해 얼마나 많은 인접한 셀이 활성 상태인지 알아야 합니다. 어떤 것이 활성 상태인지보다 개수가 중요합니다.

  1. 인접 셀 데이터를 더 쉽게 가져오려면 지정된 좌표의 cellStateIn 값을 반환하는 cellActive 함수를 추가합니다.

index.html(Compute createShaderModule 호출)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

cellActive 함수는 셀이 활성 상태인 경우 1을 반환하므로 주변 셀 8개 모두에 대해 cellActive를 호출하는 반환 값을 추가하면 활성 상태인 인접 셀 수를 알 수 있습니다.

  1. 다음과 같이 활성 상태인 인접 셀 수를 확인합니다.

index.html(Compute createShaderModule 호출)

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

하지만 이 경우 사소한 문제가 발생합니다. 확인 중인 셀이 경계 가장자리에서 벗어나면 어떻게 될까요? 현재 cellIndex() 로직에 따라 다음 또는 이전 행으로 오버플로되거나 버퍼 가장자리에서 실행됩니다.

Game of Life의 경우 이 문제를 일반적이고 간단한 방법으로 해결하는데, 그리드의 가장자리에 있는 셀이 그리드의 반대쪽 가장자리에 있는 셀을 인접 셀로 처리하여 래핑 효과를 일으키는 것입니다.

  1. cellIndex() 함수를 약간 변경하여 그리드 랩어라운드를 지원합니다.

index.html(Compute createShaderModule 호출)

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

셀 크기가 그리드 크기를 넘어 확장될 때 % 연산자를 사용하여 셀 X와 Y를 래핑하면 스토리지 버퍼 경계 외부에 액세스하지 않습니다. 이렇게 하면 activeNeighbors 수를 예측할 수 있습니다.

그런 다음 네 가지 규칙 중 하나를 적용합니다.

  • 인접 항목이 2개 미만인 셀은 비활성화됩니다.
  • 인접 항목이 2~3개인 활성 셀은 활성 상태로 유지됩니다.
  • 인접 항목이 정확히 3개인 비활성 셀은 활성화됩니다.
  • 인접 항목이 4개 이상인 셀은 비활성화됩니다.

일련의 if 문으로 이를 수행할 수 있지만 WGSL은 이 로직에 적합한 전환 문을 지원합니다.

  1. 다음과 같이 Game of Life 로직을 구현합니다.

index.html(Compute createShaderModule 호출)

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

참고로, 최종 컴퓨팅 셰이더 모듈 호출은 다음과 같습니다.

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

    @group(0) @binding(1) var<storage> cellStateIn: array<u32>;
    @group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

    fn cellIndex(cell: vec2u) -> u32 {
      return (cell.y % u32(grid.y)) * u32(grid.x) +
              (cell.x % u32(grid.x));
    }

    fn cellActive(x: u32, y: u32) -> u32 {
      return cellStateIn[cellIndex(vec2(x, y))];
    }

    @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
    fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
      // Determine how many active neighbors this cell has.
      let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                            cellActive(cell.x+1, cell.y) +
                            cellActive(cell.x+1, cell.y-1) +
                            cellActive(cell.x, cell.y-1) +
                            cellActive(cell.x-1, cell.y-1) +
                            cellActive(cell.x-1, cell.y) +
                            cellActive(cell.x-1, cell.y+1) +
                            cellActive(cell.x, cell.y+1);

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

이게 다입니다. 모두 마쳤습니다! 페이지를 새로고침하고 새로 빌드한 셀룰러 자동화가 성장하는 것을 지켜보세요.

Game of Life 시뮬레이션의 예시 스크린샷. 어두운 파란색 배경에 다채로운 셀이 렌더링되어 있습니다.

9. 축하합니다.

WebGPU API를 사용하여 전적으로 GPU에서 실행되는 클래식 Conway's Game of Life 시뮬레이션 버전을 만들었습니다.

다음 단계

추가 자료

참조 문서