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에서 생성한 자바스크립트 및 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를 사용할 수 있습니다. '일시중지'를 허용하는 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 페이지

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