Emscripten에서 캔버스에 그리기

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

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

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

기존 프로젝트를 포팅하는 대신 새 프로젝트를 시작하는 경우 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 셸을 사용하면 캔버스가 포함되어 자동으로 설정됩니다. 간단한 데모와 예제를 쉽게 제작할 수 있지만, 규모가 큰 애플리케이션에서는 Emscripten에서 생성한 자바스크립트 및 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 그래픽을 중점적으로 다룹니다. 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를 사용해야 합니다. 이렇게 하면 이미 WebAssembly로 사전 컴파일된 SDL2 라이브러리를 가져와 기본 애플리케이션과 연결하도록 Emscripten에 지시합니다.

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;페이지 응답 없음&#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 페이지

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