Emscripten에서 캔버스에 그리기

Emscripten을 사용하여 WebAssembly에서 웹에 2D 그래픽을 렌더링하는 방법을 알아봅니다.

운영체제마다 그래픽을 그리기 위한 API가 다릅니다. 크로스 플랫폼 코드를 작성하거나 네이티브 코드를 WebAssembly로 포팅하는 경우를 비롯하여 한 시스템에서 다른 시스템으로 그래픽을 포팅할 때는 이러한 차이가 더욱 혼란스러워집니다.

이 게시물에서는 Emscripten으로 컴파일된 C 또는 C++ 코드에서 웹의 캔버스 요소에 2D 그래픽을 그리는 몇 가지 메서드를 알아봅니다.

Embind를 통한 캔버스

기존 프로젝트를 포팅하는 대신 새 프로젝트를 시작하는 경우 Emscripten의 바인딩 시스템 Embind를 통해 HTML Canvas API를 사용하는 것이 가장 쉬울 수 있습니다. Embind를 사용하면 임의의 JavaScript 값을 직접 운영할 수 있습니다.

Embind를 사용하는 방법을 알아보려면 먼저 <canvas> 요소를 찾아 그 위에 도형을 그리는 다음 MDN의 예를 살펴보세요.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

Embind를 사용하여 C++로 변환하는 방법은 다음과 같습니다.

#include <emscripten/val.h>

using emscripten::val;

// Use thread_local when you want to retrieve & cache a global JS variable once per thread.
thread_local const val document = val::global("document");

// …

int main() {
  val canvas = document.call<val>("getElementById", "canvas");
  val ctx = canvas.call<val>("getContext", "2d");
  ctx.set("fillStyle", "green");
  ctx.call<void>("fillRect", 10, 10, 150, 100);
}

이 코드를 연결할 때는 Embind를 사용 설정하기 위해 --bind를 전달해야 합니다.

emcc --bind example.cpp -o example.html

그런 다음 정적 서버로 컴파일된 애셋을 제공하고 브라우저에 예시를 로드할 수 있습니다.

검은색 캔버스에 녹색 직사각형을 보여주는 Emscripten 생성 HTML 페이지

캔버스 요소 선택

이전 셸 명령어와 함께 Emscripten에서 생성된 HTML 셸을 사용하면 캔버스가 포함되고 자동으로 설정됩니다. 이렇게 하면 간단한 데모와 예시를 더 쉽게 빌드할 수 있지만, 대규모 애플리케이션에서는 자체 디자인의 HTML 페이지에 Emscripten에서 생성된 JavaScript와 WebAssembly를 포함하는 것이 좋습니다.

생성된 JavaScript 코드는 Module.canvas 속성에 저장된 캔버스 요소를 찾을 것으로 예상합니다. 다른 모듈 속성과 마찬가지로 초기화 중에 설정할 수 있습니다.

ES6 모드 (확장자가 .mjs인 경로로 출력을 설정하거나 -s EXPORT_ES6 설정을 사용)를 사용하는 경우 다음과 같이 캔버스를 전달할 수 있습니다.

import initModule from './emscripten-generated.mjs';

const Module = await initModule({
  canvas: document.getElementById('my-canvas')
});

일반 스크립트 출력을 사용하는 경우 Emscripten에서 생성된 JavaScript 파일을 로드하기 전에 Module 객체를 선언해야 합니다.

<script>
var Module = {
  canvas: document.getElementById('my-canvas')
};
</script>
<script src="emscripten-generated.js"></script>

OpenGL 및 SDL2

OpenGL은 컴퓨터 그래픽용으로 널리 사용되는 크로스 플랫폼 API입니다. Emscripten에서 사용하면 지원되는 OpenGL 작업의 하위 집합을 WebGL로 변환합니다. 애플리케이션이 OpenGL ES 2.0 또는 3.0에서 지원되지만 WebGL에서는 지원되지 않는 기능을 사용하는 경우 Emscripten에서 이러한 기능을 에뮬레이션할 수도 있지만 해당 설정을 통해 선택해야 합니다.

OpenGL을 직접 사용하거나 상위 수준의 2D 및 3D 그래픽 라이브러리를 통해 사용할 수 있습니다. 그중 몇 개는 Emscripten을 사용하여 웹으로 포팅되었습니다. 이 게시물에서는 2D 그래픽에 중점을 두고 있으며, SDL2는 현재 Emscripten 백엔드를 공식적으로 업스트림으로 지원하고 충분히 테스트를 거쳤으므로 선호되는 라이브러리입니다.

직사각형 그리기

공식 웹사이트의 'SDL 정보' 섹션에 다음과 같이 표시됩니다.

Simple DirectMedia Layer는 OpenGL 및 Direct3D를 통해 오디오, 키보드, 마우스, 조이스틱, 그래픽 하드웨어에 대한 하위 수준 액세스를 제공하도록 설계된 크로스 플랫폼 개발 라이브러리입니다.

오디오, 키보드, 마우스, 그래픽을 제어하는 모든 기능이 포팅되었으며 웹에서도 Emscripten과 함께 작동하므로 SDL2로 빌드된 전체 게임을 큰 어려움 없이 포팅할 수 있습니다. 기존 프로젝트를 포팅하는 경우 Emscripten 문서의 '빌드 시스템과의 통합' 섹션을 확인하세요.

간편하게 설명하기 위해 이 게시물에서는 단일 파일 사례에 중점을 두고 이전 직사각형 예시를 SDL2로 변환하겠습니다.

#include <SDL2/SDL.h>

int main() {
  // Initialize SDL graphics subsystem.
  SDL_Init(SDL_INIT_VIDEO);

  // Initialize a 300x300 window and a renderer.
  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  // Set a color for drawing matching the earlier `ctx.fillStyle = "green"`.
  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  // Create and draw a rectangle like in the earlier `ctx.fillRect()`.
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);

  // Render everything from a buffer to the actual screen.
  SDL_RenderPresent(renderer);

  // TODO: cleanup
}

Emscripten과 연결할 때는 -s USE_SDL=2를 사용해야 합니다. 이렇게 하면 Emscripten이 이미 WebAssembly로 사전 컴파일된 SDL2 라이브러리를 가져와 기본 애플리케이션과 연결하도록 지시합니다.

emcc example.cpp -o example.html -s USE_SDL=2

예시가 브라우저에 로드되면 익숙한 녹색 직사각형이 표시됩니다.

검은색 정사각형 캔버스에 녹색 직사각형을 보여주는 Emscripten 생성 HTML 페이지

하지만 이 코드에는 몇 가지 문제가 있습니다. 첫째, 할당된 리소스를 적절하게 정리하지 않습니다. 둘째, 웹에서는 애플리케이션 실행이 완료되더라도 페이지가 자동으로 닫히지 않으므로 캔버스의 이미지가 보존됩니다. 그러나 동일한 코드가

clang example.cpp -o example -lSDL2

실행하면 생성된 창이 잠시 깜박이다가 종료 시 즉시 닫히므로 사용자가 이미지를 볼 기회가 없습니다.

이벤트 루프 통합

더 완전하고 관용적인 예는 사용자가 애플리케이션 종료를 선택할 때까지 이벤트 루프에서 기다려야 하는 경우입니다.

#include <SDL2/SDL.h>

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  while (1) {
    SDL_Event event;
    SDL_PollEvent(&event);
    if (event.type == SDL_QUIT) {
      break;
    }
  }

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

이미지가 창에 그려지면 애플리케이션은 키보드, 마우스, 기타 사용자 이벤트를 처리할 수 있는 루프에서 대기합니다. 사용자가 창을 닫으면 SDL_QUIT 이벤트가 트리거되고 이 이벤트는 가로채져 루프가 종료됩니다. 루프가 종료되면 애플리케이션이 정리 작업을 실행한 후 종료됩니다.

이제 Linux에서 이 예를 컴파일하면 예상대로 작동하며 녹색 직사각형이 있는 300x300 창이 표시됩니다.

검은색 배경과 녹색 직사각형이 있는 정사각형 Linux 창

하지만 이 예시는 더 이상 웹에서 작동하지 않습니다. Emscripten에서 생성된 페이지가 로드 중에 즉시 정지되고 렌더링된 이미지가 표시되지 않습니다.

페이지가 응답할 때까지 기다리거나 페이지를 종료하라는 메시지가 표시된 &#39;Page Unresponsive&#39;(페이지 응답 없음) 오류 대화상자가 겹쳐진 Emscripten 생성 HTML 페이지

어떻게 된 것일까요? 'WebAssembly에서 비동기 웹 API 사용' 도움말의 답변을 인용하겠습니다.

간단히 말하자면 브라우저는 모든 코드 조각을 일종의 무한 루프로 실행하여 대기열에서 하나씩 가져옵니다. 어떤 이벤트가 트리거되면 브라우저는 해당 핸들러를 대기열에 추가하고 다음 루프 반복에서 대기열에서 가져와 실행합니다. 이 메커니즘을 사용하면 단일 스레드만 사용하여 동시 실행을 시뮬레이션하고 많은 수의 병렬 작업을 실행할 수 있습니다.

이 메커니즘에 관해 기억해야 할 중요한 점은 맞춤 JavaScript (또는 WebAssembly) 코드가 실행되는 동안 이벤트 루프가 차단된다는 것입니다.

위 예에서는 무한 이벤트 루프를 실행하지만 코드 자체는 브라우저에서 암시적으로 제공하는 다른 무한 이벤트 루프 내에서 실행됩니다. 내부 루프는 외부 루프에 제어를 포기하지 않으므로 브라우저는 외부 이벤트를 처리하거나 페이지에 항목을 그릴 기회를 얻지 못합니다.

이 문제를 해결하는 방법은 두 가지가 있습니다.

Asyncify로 이벤트 루프 차단 해제

먼저 링크된 도움말에 설명된 대로 Asyncify를 사용할 수 있습니다. C 또는 C++ 프로그램을 '일시중지'하고, 이벤트 루프에 제어를 다시 제공하고, 일부 비동기 작업이 완료되면 프로그램을 깨울 수 있는 Emscripten 기능입니다.

이러한 비동기 작업은 emscripten_sleep(0) API를 통해 표현되는 '최소 시간 동안 절전 모드'일 수도 있습니다. 루프 중간에 삽입하면 각 반복에서 컨트롤이 브라우저의 이벤트 루프로 반환되고 페이지가 계속 반응하며 모든 이벤트를 처리할 수 있습니다.

#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_Window *window;
  SDL_Renderer *renderer;
  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  while (1) {
    SDL_Event event;
    SDL_PollEvent(&event);
    if (event.type == SDL_QUIT) {
      break;
    }
#ifdef __EMSCRIPTEN__
    emscripten_sleep(0);
#endif
  }

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

이제 이 코드를 Asyncify를 사용 설정하여 컴파일해야 합니다.

emcc example.cpp -o example.html -s USE_SDL=2 -s ASYNCIFY

이제 애플리케이션이 웹에서 다시 예상대로 작동합니다.

검은색 정사각형 캔버스에 녹색 직사각형을 보여주는 Emscripten 생성 HTML 페이지

그러나 Asyncify에는 상당한 코드 크기 오버헤드가 있을 수 있습니다. 애플리케이션의 최상위 이벤트 루프에만 사용되는 경우 emscripten_set_main_loop 함수를 사용하는 것이 더 좋습니다.

'메인 루프' API로 이벤트 루프 차단 해제

emscripten_set_main_loop는 호출 스택을 롤백하고 되감기하기 위해 컴파일러 변환이 필요하지 않으므로 코드 크기 오버헤드를 방지할 수 있습니다. 하지만 그 대신 코드를 수동으로 더 많이 수정해야 합니다.

먼저 이벤트 루프의 본문을 별도의 함수로 추출해야 합니다. 그런 다음 emscripten_set_main_loop를 첫 번째 인수의 콜백으로, 두 번째 인수의 FPS (네이티브 새로고침 간격의 경우 0)로, 세 번째 인수의 무한 루프 시뮬레이션 여부를 나타내는 불리언 (true)으로 호출해야 합니다.

emscripten_set_main_loop(callback, 0, true);

새로 생성된 콜백은 main 함수의 스택 변수에 액세스할 수 없으므로 windowrenderer와 같은 변수를 힙 할당 구조체로 추출하고 API의 emscripten_set_main_loop_arg 변형을 통해 포인터를 전달하거나 전역 static 변수로 추출해야 합니다 (간단히 하기 위해 후자를 사용함). 결과는 약간 더 파악하기 어렵지만 이전 예시와 동일한 직사각형을 그립니다.

#include <SDL2/SDL.h>
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);

  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

모든 제어 흐름 변경사항은 수동으로 이루어지고 소스 코드에 반영되므로 Asyncify 기능 없이 다시 컴파일할 수 있습니다.

emcc example.cpp -o example.html -s USE_SDL=2

이 예시는 코드가 훨씬 간단해졌음에도 불구하고 직사각형이 캔버스에 성공적으로 그려진 첫 번째 버전과 다르게 작동하지 않으며 handle_events 함수에서 처리되는 유일한 SDL_QUIT 이벤트가 웹에서 무시되므로 쓸모없는 것처럼 보일 수 있습니다.

그러나 Asyncify 또는 emscripten_set_main_loop를 통한 적절한 이벤트 루프 통합은 어떤 종류의 애니메이션이나 상호작용도 추가하려는 경우 유용합니다.

사용자 상호작용 처리

예를 들어 마지막 예시를 약간 변경하면 키보드 이벤트에 반응하여 직사각형이 움직이도록 할 수 있습니다.

#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

SDL_Rect rect = {.x = 10, .y = 10, .w = 150, .h = 100};

void redraw() {
  SDL_SetRenderDrawColor(renderer, /* RGBA: black */ 0x00, 0x00, 0x00, 0xFF);
  SDL_RenderClear(renderer);
  SDL_SetRenderDrawColor(renderer, /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_RenderFillRect(renderer, &rect);
  SDL_RenderPresent(renderer);
}

uint32_t ticksForNextKeyDown = 0;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  if (event.type == SDL_KEYDOWN) {
    uint32_t ticksNow = SDL_GetTicks();
    if (SDL_TICKS_PASSED(ticksNow, ticksForNextKeyDown)) {
      // Throttle keydown events for 10ms.
      ticksForNextKeyDown = ticksNow + 10;
      switch (event.key.keysym.sym) {
        case SDLK_UP:
          rect.y -= 1;
          break;
        case SDLK_DOWN:
          rect.y += 1;
          break;
        case SDLK_RIGHT:
          rect.x += 1;
          break;
        case SDLK_LEFT:
          rect.x -= 1;
          break;
      }
      redraw();
    }
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  redraw();
  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

SDL2_gfx로 다른 도형 그리기

SDL2는 단일 API에서 크로스 플랫폼 차이와 다양한 유형의 미디어 기기를 추상화하지만 여전히 상당히 하위 수준의 라이브러리입니다. 특히 그래픽의 경우 점, 선, 직사각형을 그리기 위한 API를 제공하지만 더 복잡한 도형과 변환은 사용자에게 맡겨집니다.

SDL2_gfx는 이 격차를 메우는 별도의 라이브러리입니다. 예를 들어 위의 예에서 직사각형을 원으로 대체하는 데 사용할 수 있습니다.

#include <SDL2/SDL.h>
#include <SDL2/SDL2_gfxPrimitives.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

SDL_Window *window;
SDL_Renderer *renderer;

SDL_Point center = {.x = 100, .y = 100};
const int radius = 100;

void redraw() {
  SDL_SetRenderDrawColor(renderer, /* RGBA: black */ 0x00, 0x00, 0x00, 0xFF);
  SDL_RenderClear(renderer);
  filledCircleRGBA(renderer, center.x, center.y, radius,
                   /* RGBA: green */ 0x00, 0x80, 0x00, 0xFF);
  SDL_RenderPresent(renderer);
}

uint32_t ticksForNextKeyDown = 0;

bool handle_events() {
  SDL_Event event;
  SDL_PollEvent(&event);
  if (event.type == SDL_QUIT) {
    return false;
  }
  if (event.type == SDL_KEYDOWN) {
    uint32_t ticksNow = SDL_GetTicks();
    if (SDL_TICKS_PASSED(ticksNow, ticksForNextKeyDown)) {
      // Throttle keydown events for 10ms.
      ticksForNextKeyDown = ticksNow + 10;
      switch (event.key.keysym.sym) {
        case SDLK_UP:
          center.y -= 1;
          break;
        case SDLK_DOWN:
          center.y += 1;
          break;
        case SDLK_RIGHT:
          center.x += 1;
          break;
        case SDLK_LEFT:
          center.x -= 1;
          break;
      }
      redraw();
    }
  }
  return true;
}

void run_main_loop() {
#ifdef __EMSCRIPTEN__
  emscripten_set_main_loop([]() { handle_events(); }, 0, true);
#else
  while (handle_events())
    ;
#endif
}

int main() {
  SDL_Init(SDL_INIT_VIDEO);

  SDL_CreateWindowAndRenderer(300, 300, 0, &window, &renderer);

  redraw();
  run_main_loop();

  SDL_DestroyRenderer(renderer);
  SDL_DestroyWindow(window);

  SDL_Quit();
}

이제 SDL2_gfx 라이브러리도 애플리케이션에 연결해야 합니다. SDL2와 비슷하게 실행됩니다.

# Native version
$ clang example.cpp -o example -lSDL2 -lSDL2_gfx
# Web version
$ emcc --bind foo.cpp -o foo.html -s USE_SDL=2 -s USE_SDL_GFX=2

다음은 Linux에서 실행된 결과입니다.

검은색 배경과 녹색 원이 있는 정사각형 Linux 창

웹에서

검은색 정사각형 캔버스에 녹색 원을 보여주는 Emscripten 생성 HTML 페이지

그래픽 원시 항목에 관한 자세한 내용은 자동 생성 문서를 참고하세요.