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);
}

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

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

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

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

캔버스 요소 선택하기

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

생성된 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 레이어는 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에서 생성된 페이지는 로드 중에 즉시 정지되고 렌더링된 이미지가 표시되지 않습니다.

엠스크립트로 생성된 HTML 페이지에 &#39;페이지 응답 없음&#39; 오류 대화상자가 표시되어 페이지가 실행될 때까지 기다리거나 페이지를 종료할 것을 제안합니다.

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

짧은 버전은 브라우저가 대기열에서 코드를 하나씩 가져와 모든 코드를 무한 루프로 실행하는 것입니다. 일부 이벤트가 트리거되면 브라우저는 해당 핸들러를 큐에 추가하고 다음 루프 반복 시 큐에서 추출되어 실행됩니다. 이 메커니즘을 사용하면 단일 스레드만 사용하는 동안 동시 실행을 시뮬레이션하고 많은 수의 병렬 작업을 실행할 수 있습니다.

이 메커니즘에 대해 기억해야 할 중요한 점은 맞춤 자바스크립트 (또는 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는 호출 스택을 해제하고 되감기 위한 컴파일러 변환이 필요하지 않으므로 코드 크기 오버헤드가 방지됩니다. 그 대신 코드를 직접 수정해야 합니다.

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

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 창

웹:

검은색 정사각형 캔버스에 녹색 원이 표시된 엠스크립트 생성 HTML 페이지

더 많은 그래픽 프리미티브는 자동 생성 문서를 참고하세요.