SW/JavaScript

taichi.js: 간편한 WebGPU 프로그래밍 가이드

얇은생각 2024. 6. 13. 07:30
반응형

컴퓨터 그래픽스와 프로그래밍 언어에 열정을 가진 저는 지난 2년 동안 여러 GPU 컴파일러 작업을 수행하게 되어 기쁘게 생각합니다. 2021년에 시작된 이 여정은 Python 함수들을 CUDA, Metal, 또는 Vulkan GPU 커널로 컴파일하는 Python 라이브러리인 Taichi에 기여하면서 시작되었습니다. 이후 Meta에 합류하여 Instagram Facebook AR 효과를 위한 크로스 플랫폼 GPU 프로그래밍을 지원하는 쉐이더 언어인 SparkSL 작업을 시작했습니다. 이러한 프레임워크들이 실제로 유용하며, 복잡한 GPU 개념을 숙달하지 않고도 매력적인 그래픽 콘텐츠를 생성할 수 있도록 도와주는 것이라 믿고 있습니다.

최신 프로젝트에서는 웹을 위한 차세대 그래픽 API WebGPU에 주목했습니다. WebGPU는 낮은 CPU 오버헤드와 명시적인 GPU 제어를 통해 고성능 그래픽스를 제공하겠다는 약속을 지키고 있습니다. 그러나 Vulkan과 마찬가지로 WebGPU의 성능 이점은 높은 학습 곡선을 동반합니다. 많은 재능 있는 프로그래머들이 WebGPU를 사용해 놀라운 콘텐츠를 만들 것이라고 확신하지만, 저는 사람들이 그 복잡성을 마주하지 않고도 WebGPU를 활용할 수 있는 방법을 제공하고 싶었습니다. 그래서 taichi.js가 탄생하게 되었습니다.

taichi.js 프로그래밍 모델에서는 프로그래머가 디바이스, 명령 큐, 바인드 그룹 등의 WebGPU 개념을 이해할 필요가 없습니다. 대신 간단한 JavaScript 함수만 작성하면 컴파일러가 해당 함수들을 WebGPU 계산 또는 렌더 파이프라인으로 변환합니다. 이는 기본적인 JavaScript 구문을 이해하는 누구나 taichi.js를 통해 WebGPU 코드를 작성할 수 있음을 의미합니다.

 

 

taichi.js: 간편한 WebGPU 프로그래밍 가이드

 

 

taichi.js를 사용한 "Game of Life" 구현

"Game of Life"는 셀룰러 오토마톤의 고전적인 예로, 간단한 규칙에 따라 시간이 지남에 따라 진화하는 셀 시스템입니다. 1970년 수학자 존 콘웨이에 의해 발명되었으며, 그 이후로 컴퓨터 과학자와 수학자들 사이에서 인기를 끌고 있습니다. 게임은 각 셀이 살아 있거나 죽을 수 있는 2차원 그리드에서 진행됩니다. 게임의 규칙은 다음과 같습니다:

  1. 살아있는 셀이 두 개 미만 또는 세 개 초과의 살아있는 이웃을 가지면 죽습니다.
  2. 죽은 셀이 정확히 세 개의 살아있는 이웃을 가지면 살아납니다.

그 간단함에도 불구하고, "Game of Life"는 놀라운 행동을 보여줄 수 있습니다. 임의의 초기 상태에서 시작하여, 게임은 종종 몇 가지 패턴이 지배적인 상태로 수렴하는데, 이는 마치 ""이 진화를 통해 생존한 것처럼 보입니다.

 

시뮬레이션 구현

taichi.js를 사용한 "Game of Life" 구현을 시작하기 위해 taichi.js 라이브러리를 ti라는 약칭으로 가져오고 모든 논리를 포함할 비동기 main() 함수를 정의합니다. main() 함수 내에서 ti.init()을 호출하여 라이브러리와 WebGPU 컨텍스트를 초기화합니다.

import * as ti from "path/to/taichi.js";

let main = async () => {
    await ti.init();
    ...
};

main();

 

let N = 128;

let liveness = ti.field(ti.i32, [N, N]);
let numNeighbors = ti.field(ti.i32, [N, N]);

ti.addToKernelScope({ N, liveness, numNeighbors });

 

여기서 liveness numNeighbors라는 두 변수를 정의했습니다. taichi.js에서 "field" n차원 배열이며, ti.field()의 두 번째 인수로 차원을 제공합니다. 배열의 요소 유형은 첫 번째 인수로 정의됩니다. 이 경우 ti.i32 32비트 정수를 나타냅니다. 그러나 필드 요소는 벡터, 행렬, 구조체와 같은 더 복잡한 유형도 될 수 있습니다.

 

다음으로, ti.addToKernelScope({...}) N, liveness numNeighbors 변수를 taichi.js "kernel"에서 사용할 수 있도록 합니다. 커널은 JavaScript 함수 형태로 정의된 GPU 계산 또는 렌더 파이프라인입니다. 예를 들어, 다음 초기화 커널은 각 셀이 초기 상태에서 살아있을 확률이 20%인 초기 liveness 값을 채웁니다:

let init = ti.kernel(() => {
    for (let I of ti.ndrange(N, N)) {
        liveness[I] = 0;
        let f = ti.random();
        if (f < 0.2) {
            liveness[I] = 1;
        }
    }
});
init();

 

 

init() 커널은 JavaScript 람다를 인수로 하여 ti.kernel()을 호출함으로써 생성됩니다. taichi.js는 이 람다의 JavaScript 문자열 표현을 분석하여 해당 로직을 WebGPU 코드로 컴파일합니다. 여기서 람다는 ti.ndrange(N, N)을 통해 모든 셀을 순회하는 for 루프를 포함합니다. 이는 I [0, 0]에서 [N-1, N-1]까지 NxN 개의 값을 취함을 의미합니다.

taichi.js에서는 커널의 모든 최상위 for 루프가 병렬화됩니다. , 루프 인덱스의 각 가능한 값에 대해 taichi.js는 하나의 WebGPU 계산 쉐이더 스레드를 할당하여 이를 실행합니다. 이 경우 "Game of Life" 시뮬레이션의 각 셀에 하나의 GPU 스레드를 할당하여 무작위 liveness 상태로 초기화합니다. 무작위성은 taichi.js 라이브러리에서 커널 사용을 위해 제공되는 많은 함수 중 하나인 ti.random() 함수에서 나옵니다. 이러한 내장 유틸리티의 전체 목록은 taichi.js 문서에서 확인할 수 있습니다.

게임의 초기 상태를 생성한 후, 게임이 어떻게 진화하는지 정의하겠습니다. 다음은 이 진화를 정의하는 두 개의 taichi.js 커널입니다:

let countNeighbors = ti.kernel(() => {
    for (let I of ti.ndrange(N, N)) {
        let neighbors = 0;
        for (let delta of ti.ndrange(3, 3)) {
            let J = (I + delta - 1) % N;
            if ((J.x != I.x || J.y != I.y) && liveness[J] == 1) {
                neighbors = neighbors + 1;
            }
        }
        numNeighbors[I] = neighbors;
    }
});

let updateLiveness = ti.kernel(() => {
    for (let I of ti.ndrange(N, N)) {
        let neighbors = numNeighbors[I];
        if (liveness[I] == 1) {
            if (neighbors < 2 || neighbors > 3) {
                liveness[I] = 0;
            }
        } else {
            if (neighbors == 3) {
                liveness[I] = 1;
            }
        }
    }
});

 

 

이전의 init() 커널과 마찬가지로, 이 두 커널도 모든 그리드 셀을 순회하는 최상위 for 루프가 있으며, 컴파일러에 의해 병렬화됩니다. countNeighbors()에서는 각 셀에 대해 8개의 이웃 셀을 확인하고 이웃 중 몇 개가 살아있는지 세어 numNeighbors 필드에 저장합니다. 이웃을 순회할 때 ti.ndrange(3, 3)을 사용하여 루프를 돌며, delta [0, 0]에서 [2, 2]까지 범위를 가지며 원래 셀 인덱스 I를 오프셋하는 데 사용됩니다. 경계 밖 접근을 방지하기 위해 N으로 모듈로 연산을 수행합니다. (위상학적으로는, 이는 게임이 토로이드 경계 조건을 가진다는 것을 의미합니다).

각 셀의 이웃 수를 센 후, updateLiveness() 커널에서 해당 셀의 liveness 상태를 업데이트합니다. 이 과정은 각 셀의 liveness 상태와 현재 살아있는 이웃 수를 읽고 게임 규칙에 따라 새로운 liveness 값을 기록하는 간단한 과정입니다. 이 과정은 모든 셀에 병렬로 적용됩니다.

이로써 게임의 시뮬레이션 로직 구현이 완료되었습니다. 다음으로, 웹 페이지에 게임의 진화를 그리기 위한 WebGPU 렌더 파이프라인을 정의하는 방법을 살펴보겠습니다.

 

렌더링

taichi.js에서 렌더링 코드를 작성하는 것은 일반적인 계산 커널을 작성하는 것보다 약간 더 복잡하며, 버텍스 쉐이더, 프래그먼트 쉐이더 및 래스터화 파이프라인에 대한 이해를 요구합니다. 그러나 taichi.js의 간단한 프로그래밍 모델은 이러한 개념을 매우 쉽게 다룰 수 있게 해줍니다.

무엇인가를 그리기 전에, 우리가 그릴 캔버스에 접근해야 합니다. HTML result_canvas라는 이름의 캔버스가 있다고 가정하면, 다음 코드는 taichi.js 렌더 파이프라인으로 렌더링할 수 있는 텍스처 조각을 나타내는 ti.CanvasTexture 객체를 생성합니다:

let htmlCanvas = document.getElementById('result_canvas');
htmlCanvas.width = 512;
htmlCanvas.height = 512;
let renderTarget = ti.canvasTexture(htmlCanvas);

 

 

이제 캔버스에 사각형을 렌더링하고, 게임의 2D 그리드를 이 사각형에 그릴 것입니다. GPU에서는 렌더링할 기하학적 도형이 삼각형 형태로 표현됩니다. 이 경우, 렌더링하려는 사각형은 두 개의 삼각형으로 표현됩니다. 이 두 삼각형은 각각의 여섯 개의 꼭짓점 좌표를 저장하는 ti.field에 정의됩니다:

let vertices = ti.field(ti.types.vector(ti.f32, 2), [6]);
await vertices.fromArray([
    [-1, -1],
    [1, -1],
    [-1, 1],
    [1, -1],
    [1, 1],
    [-1, 1],
]);

 

 

liveness numNeighbors 필드와 마찬가지로, taichi.js GPU 커널에서 renderTarget vertices 변수를 명시적으로 선언해야 합니다:

ti.addToKernelScope({ vertices, renderTarget });

 

 

이제 렌더 파이프라인을 구현하기 위한 모든 데이터를 준비했습니다. 다음은 파이프라인 자체의 구현입니다:

let render = ti.kernel(() => {
    ti.clearColor(renderTarget, [0.0, 0.0, 0.0, 1.0]);
    for (let v of ti.inputVertices(vertices)) {
        ti.outputPosition([v.x, v.y, 0.0, 1.0]);
        ti.outputVertex(v);
    }
    for (let f of ti.inputFragments()) {
        let coord = (f + 1) / 2.0;
        let texelIndex = ti.i32(coord * (liveness.dimensions - 1));
        let live = ti.f32(liveness[texelIndex]);
        ti.outputColor(renderTarget, [live, live, live, 1.0]);
    }
});

 

 

render() 커널 내부에서, 먼저 renderTarget [0.0, 0.0, 0.0, 1.0]의 올 블랙 색상으로 지웁니다.

다음으로, 두 개의 최상위 for 루프를 정의합니다. 이 루프들은 WebGPU에서 병렬화되며, ti.inputVertices(vertices) ti.inputFragments()를 각각 순회합니다. 이는 이 루프들이 WebGPU "버텍스 쉐이더" "프래그먼트 쉐이더"로 컴파일되어 렌더 파이프라인으로 작동함을 나타냅니다.

 

버텍스 쉐이더의 역할은 두 가지입니다:

  1. 각 삼각형 버텍스의 최종 위치를 화면에 계산합니다. 3D 렌더링 파이프라인에서는 모델 좌표를 월드 공간, 카메라 공간을 거쳐 클립 공간으로 변환하는 행렬 곱셈이 포함됩니다. 그러나 간단한 2D 사각형의 경우, 입력 좌표는 이미 클립 공간에 있어 고정된 z 0.0과 고정된 w 1.0을 추가하기만 하면 됩니다.
ti.outputPosition([v.x, v.y, 0.0, 1.0]);

 

 

  1. 각 버텍스에서 프래그먼트 쉐이더로 전달될 보간 데이터를 생성합니다. 이 보간 데이터는 렌더링 파이프라인의 래스터화 단계에서 하드웨어 가속을 통해 계산됩니다. 각 프래그먼트에 대해 해당 프래그먼트 쉐이더 스레드는 보간된 값을 수신합니다.

우리의 경우, 프래그먼트 쉐이더는 2D 사각형 내에서 프래그먼트의 위치를 알아야 게임의 liveness 값을 가져올 수 있습니다. 이를 위해 2D 버텍스 좌표를 래스터화기로 전달하면 충분합니다:

ti.outputVertex(v);

 

 

프래그먼트 쉐이더로 이동해 보겠습니다:

for (let f of ti.inputFragments()) {
    let coord = (f + 1) / 2.0;
    let cellIndex = ti.i32(coord * (liveness.dimensions - 1));
    let live = ti.f32(liveness[cellIndex]);
    ti.outputColor(renderTarget, [live, live, live, 1.0]);
}

 

 

f 값은 버텍스 쉐이더에서 전달된 보간된 픽셀 위치입니다. 이를 사용하여 프래그먼트 쉐이더는 해당 픽셀을 덮고 있는 게임 셀의 liveness 상태를 조회합니다. 이 값 f [0, 0] ~ [1, 1] 범위로 변환한 후, liveness 필드의 차원과 곱하여 덮고 있는 셀의 인덱스를 생성합니다. 마지막으로, 이 셀의 live 값을 가져와 픽셀이 차지하는 위치에 따라 RGBA 값을 renderTarget에 출력합니다.

렌더 파이프라인을 정의한 후, 각 프레임마다 시뮬레이션 커널과 렌더 파이프라인을 호출하여 모든 작업을 결합합니다:

async function frame() {
    countNeighbors();
    updateLiveness();
    await render();
    requestAnimationFrame(frame);
}

await frame();

 

 

이로써 taichi.js를 사용한 WebGPU 기반 "Game of Life" 구현이 완료되었습니다. 프로그램을 실행하면 128x128 셀이 약 1400 세대 동안 진화하는 애니메이션을 볼 수 있습니다.

반응형