만들리에/오락실 (스트리밍 게임)

추억의 오락실 (4/11) - 프로토타이핑

gamz 2021. 1. 16. 01:31

목표

이전 포스트의 스케치에서 어떤식으로 동작하면 되겠다라는 대충의 윤곽이 그려졌고 이를 실제로 구현해낼 수 있는지 프로토타이핑으로 검증하는 단계를 가져봤다. 이때 작업했던 것들이 제일 고통스러웠지만 가장 핵심적이기도 한 내용들이었다.

 

프로토타이핑의 목표는,

"마메(MAME)에서 렌더링된 프레임을 인코딩해서 WebRTC 를 통해 브라우저로 스트리밍한다"

이를 위해 크게 3파트로 나눠서 진행했다.

 

1. 마메(MAME) 에뮬레이터로부터 이미지 프레임 추출

일단 한장이라도 추출할 수 있으면 구현 가능성이 급격히 올라간다. 

 

2. Headless MAME 라이브러리 제작

마메(MAME)를 빌드하면 기본적으로 sdl2 기반 UI를 가진 앱이 생성되는데 우리는 이미지/오디오 프레임 데이터만 가져올 수 있으면 되기 때문에 이 UI까지 들고갈 필요는 없다. 그래서 마메에서 이것들을 제거하고 경량화한 라이브러리로 만드는 작업이 필요하다.  

 

3. Encoding 과 WebRTC 스트리밍

에뮬레이터에서 뽑아낸 프레임 데이터를 전달받아(IPC) 이를 인코딩하여 브라우저에 전송하고 유저의 입력을 에뮬레이터로 넘겨주는 웹서비스 레이어가 필요하다.

프로토타입 아키텍쳐

1. 마메(MAME) headless 라이브러리

마메앱을 dynamic linking 용 라이브러리로 변경. 게임을 구동할 수 있고, 키입력을 넣을 수 있고, 이미지 및 오디오 프레임을 가져올 수 있는 interface 제공. 

 

2. 콘솔앱 (에뮬레이터 Wrapper)

에뮬레이터를 구동하고 웹서버와 브릿지 역할을 하는 앱. 웹서버와 따로 분리한 이유는 웹서버는 에뮬레이터가 제공하는 데이터나 기능의 응용계층이라고 봤고 이 응용계층에는 웹 뿐만 아니라 앱 등 다양한 형태가 있을 수 있다고 가정했다. (같은 웹이어도 어떤 언어/프레임워크를 쓸지에 대해서도 제약을 주지 않기 위해) 반대로 에뮬레이터도 MAME 뿐만 아니라 다른 에뮬레이터를 지원할 수도 있다고 보고 이것들의 공통된 인터페이스를 제공하는 레이어라는 관점으로 분리했다.

 

3. 에뮬레이터와 웹서버의 통신 방식

콘솔앱과 웹서버간의 커플링을 느슨하게 하기 위해 파일 혹은 IPC를 이용한다. 이미지 및 오디오 데이터를 각각 파일에 in-place 로 쓰면 웹서버에서 read only 모드로 읽어가고, 웹서버를 통해 받은 유저 키입력은 IPC 를 통해 콘솔앱으로 전달한다.

 

4. 웹서버

콘솔앱(과 공유된 파일)으로부터 읽어온 이미지/오디오 데이터를 인코딩하고 WebRTC 시그널링을 통해 맺어진 프론트 피어(Peer)에 인코딩된 데이터를 전송한다. 데이터 채널로는 프론트의 키입력을 전송받고 이를 IPC를 통해 콘솔앱으로 전달한다.

 

5. 프론트

WebRTC 시그널링, 동영상 재생(video element), 데이터채널로 키입력 전달할 수 있는 아주 간단한 기능만 포함.

 

프레임 업데이트 훅(Hook) 찾기 & 이미지 프레임 버퍼 획듯

일단은 프레임이 업데이트되고 렌더링되는 핵심 콜 플로우(Call-flow)를 찾아야 한다. 약간(?)의 마메(MAME) 소스코드 분석이 필요한데 큰 그림을 일단 그려보자면 이렇다.

 

크게 OSD(On Screen Display) Layer 와  Emulator Machine 로 구성된다. OSD는 일종의 Presenter 영역으로 mac, sdl, windows 등이 있는데 기본은 sdl 로 되어있다. 나중에 우리는 이 OSD 영역에서 UI가 없는 Headless OSD 를 만들면 될 것 같다. OSD 가 의존하고 있는 Emulator Machine 이 Core 인데, machine.h 파일로부터 따라가볼 수 있다. 디바이스, 각종 매니저들, 게임 드라이버들을 모두 품고 있다.

 

나의 관심사는 Emulator Machine 에서 Tick 에 맞게 프레임을 잘 업데이트 한후 OSD 로 넘어가는 부분(Call back)을 찾는 것이다. 그 부분의 코드를 발췌해보면(아래), video_manager::frame_update 에서 machine().osd().update() 호출하는 부분이 보인다. 아무래도 이 시점에 machine() 이 가지고 있는 뭔가를 이용해서 이미지 프레임을 가져올 수 있을 것 같은 느낌이다. (더 구체적인 내용은 여기를 참고)

@src/emu/video.cpp
void video_manager::frame_update(bool from_debugger) {
  if (phase == machine_phase::RUNNING && (!machine().paused() || machine().options().update_in_pause())) {
    bool anything_changed = finish_screen_updates();  
    // if none of the screens changed and we haven't skipped too many frames in a row,
    // mark this frame as skipped to prevent throttling; this helps for games that
    // don't update their screen at the monitor refresh rate
    if (!anything_changed && !m_auto_frameskip && m_frameskip_level == 0 && m_empty_skip_count++ < 3)
      skipped_it = true;
    else
      m_empty_skip_count = 0;
  }

  if (!from_debugger && !skipped_it && effective_throttle())
    update_throttle(current_time);
  ...

  machine().osd().update(!from_debugger && skipped_it);  // 여기서 OSD Layer 로 콜백
  ...
}

@src/osd/sdl/video.cpp
void sdl_osd_interface::update(bool skip_redraw) {
  // 여기서 어떻게든 이미지 프레임을 가져올 수 있을 것 같음
}

그럼 이제 어떻게 이미지를 가져올지를 보자면, 현재 머신이 들고 있는 render_manager 로부터 render_target 을 하나 가져와 view index / bounds 를 세팅해주고 S/W renderer 를 이용해 현재 view의 primitives 로부터 buffer 에 rgb 를 그려낸다. (render_target, s/w renderer 이런 것들을 찾아내는게 꽤나 고통이긴 했지만 그냥 열심히 뒤져봤다는 것 말고는 유의미한 내용이 없어서 자세한 설명은 생략하겠습니다.) 

@src/osd/sdl/osdsdl.h
class sdl_osd_interface : public osd_common_t
{
...
private:
  uint8_t *m_buffer;
  render_target *m_target;
...
}


@src/osd/sdl/video.cpp
#include "rendersw.hxx"
...
bool sdl_osd_interface::video_init()
{
  ...
  m_target = machine().render().target_alloc();
  int viewindex = 0;
  target->set_view(viewindex);
  target->set_bounds(m_width, m_height, 1.f);
  ...
}

void sdl_osd_interface::update(bool skip_redraw) {
  ...
  auto &prim = m_target->get_primitives();
  prim.acquire_lock();
  software_renderer<uint32_t, 0, 0, 0, 16, 8, 0>::draw_primitives(
    prim,
    m_buffer,
    m_width,
    m_height,
    m_width
  );
  prim.release_lock();
  ...
}

 

마메(MAME) Headless 버전 라이브러리

마메를 라이브러리화 하긴 했지만 자체 UI(Window)가 떠서 원래 하던 일들을 계속 하고 있다. 우리는 이걸 서버로 돌릴거라 화면을 그리는데 들어가는 비용은 불필요하다. 그래서 UI 걷어내는 일을 했다.

 

다행히도 MAME 코드에서 이 OSD 부분의 코드가 Open-Closed(OCP) 구조를 잘 잡아놔서 새로운 Headless OSD를 지원하기 위해 기존 코드 변경은 필요 없고 새로운 OSD 관련 코드들만 추가하면 된다.

 

우리가 만들어내려고 하는 MAME Headless Library와 이를 사용하는 클라이언트(앱)의 구조는 아래 그림처럼 가려고 한다. 검은색은 의존의 방향이고 빨간색은 제어의 방향이다. mame machine 이 다른 계층에 있는 headless osd 를 직접 의존하지 않기 위해 인터페이스를 이용해 의존하고 있는데(DIP) 그 인터페이스가 osd interface 이다.

 

그래서 시작은 osd_interface 에서부터 하면 된다. 이 인터페이스는 machine 에서 참조하는 인터페이스로 실제 구현체는 frontend 영역에서 machine 이 생성될때 주입된다.

@src/osd/osdepend.h
class osd_interface
{
public:
  virtual void init(running_machine &machine) = 0;
  virtual void update(bool skip_redraw) = 0;
  virtual void update_audio_stream(const int16_t *buffer, int samples_this_frame) = 0;
  virtual void input_update() = 0;

  ...
  
  protected:
  virtual ~osd_interface() { }
};

이 인터페이스를 구현하면서 매 프레임마다 이미지/오디오 데이터(버퍼)를 전달 받을 수 있게 콜백을 등록하는 메소드도 추가했다.

@osd/headless/headless_osd.h
typedef struct {
  int width;
  int height;
  uint8_t *buffer;
} image_frame_buf_info_t;

typedef struct {
  const int16_t *buffer;
  const int sample_rate;
  const int samples;
  const int channels;
} sound_frame_buf_info_t;

typedef void (*image_frame_callback_t)(bool);
typedef void (*sound_frame_callback_t)(sound_frame_buf_info_t);

class headless_osd_interface : public osd_common_t
{
public:
  headless_osd_interface(osd_options &options);
  virtual ~headless_osd_interface();
  
  virtual void init(running_machine &machine) override;
  virtual void update(bool skip_redraw) override;
  virtual void update_audio_stream(const int16_t *buffer, int samples_this_frame) override;
  virtual void input_update() override;
  
  ...
  
  // 이 인터페이스 유저가 이미지 버퍼 메모리를 할당해서 넘겨줄 수 있도록
  void set_image_buffer_info(image_frame_buf_info_t *buf_info) { m_buffer_info = buf_info; }
  
  // 매 프레임마다 프레임 콜백을 받을 수 있도록 등록 메소드 제공
  void set_image_frame_cb(image_frame_callback_t fp) { m_image_frame_cb = fp; }
  void set_sound_frame_cb(sound_frame_callback_t fp) { m_sound_frame_cb = fp; }
  ...
};

이렇게 새로운 Headless에 대한 OSD 레이어 구현을 하고 이제 MAME 라이브러리를 링크하는 앱에 제공할 주요 기능들을 담은 인터페이스를 정의하자. 이 헤더파일은 라이브러리를 가져다 쓰는 쪽을 위한 명세로 name mangling 을 피하기 위해 extern "C" 옵션을 주었고 binding 하는 언어에 따라 class 지원을 잘 못할 수도 있어서 무난한 struct + function ptr 를 이용해 정의했다.

 

get_mame_instance()로 singleton mame_t 구조체 인스턴스를 반환해주며, 이 인스턴스의 메소드로는 이미지 프레임 정보 설정(set_image_frame_info) , 프레임 콜백 등록(set_image_frame_cb, set_sound_frame_cb), 입력이벤트 큐잉(enqueue_input_event), 에뮬레이터(게임) 실행/중지/재개(run, pause, resume)을 제공한다.

@osd/headless/headless.h
#include <cstdint>
#include <cstddef>

#ifndef MAME_HEADLESS_H
#define MAME_HEADLESS_H

extern "C" {
  typedef enum {
    INPUT_KEY_DOWN,
    INPUT_KEY_UP
  } mame_input_enum_t;

  typedef struct {
    uint8_t key;
    mame_input_enum_t type;
  } mame_input_event_t;

  typedef struct {
    uint8_t *buffer;
    size_t buf_size;
  } mame_image_frame_t;

  typedef struct {
    const int16_t *buffer;
    const int sample_rate;
    const int samples;
    const int channels;
  } mame_sound_frame_t;

  typedef void (*mame_image_frame_cb_t)(void *ctx, mame_image_frame_t frame);
  typedef void (*mame_sound_frame_cb_t)(void *ctx, mame_sound_frame_t frame);

  typedef struct {
    void (*set_image_frame_info)(int w, int h);
    void (*set_image_frame_cb)(void *ctx, mame_image_frame_cb_t frame_cb);
    void (*set_sound_frame_cb)(void *ctx, mame_sound_frame_cb_t frame_cb);
    void (*enqueue_input_event)(mame_input_event_t input_event);

    int (*run)(const char *system_name);
    void (*pause)();
    void (*resume)();
  } mame_t;

  mame_t* get_mame_instance();
}

#endif

이제 빌드를 하면 libmame64.so 파일이 생성된다. 이제 이 shared library 와 위의 헤더파일만 있으면 MAME 에뮬레이터를 어디든 가져다 쓸 수 있는 상태가 된다. 

make -j3 OSD=headless DEBUG=0 LTO=0 NO_USE_PORTAUDIO=1 ARCHOPTS="-fPIC -Wno-error=maybe-uninitialized" LDOPTS="-Wl,-u -Wl,get_mame_instance"

* 위 빌드 커맨드에서 특이사항(한 2주일 삽질)이 하나 있는데 링크옵션 -Wl,get_mame_instance 이 부분이다. 저걸 안주면 컴파일러가 링크할때 해당 심볼을 최적화 차원에서 제거를 해버린다. 라이브러리 특성상 저 심볼은 MAME 코드에서 어디에서도 참조하고 있지 않기 때문이다. 그래서 클라이언트(앱)에서 libmame64.so를 링크하려고 할때 symbol not found 링크에러가 나는데 저 옵션을 주면 해당 심볼에 대해서 아무 참조가 없어도 일단 살려는 주기 때문에 링크에러를 피할 수 있다.

 

더 자세한 구현 내용은 여기를 참고.

 

콘솔앱

이제 MAME 라이브러리를 만들었으니 이걸 이용해서 에뮬레이터를 실행하고 프레임이미지를 가져와 파일에 쓰는 앱을 만들어 본다. 주요 코드만 한번 살펴보면, headless.h 에 선언된 get_mame_instance() 를 통해 mame_t instance 를 하나 가져와서 프레임 정보와 콜백을 세팅해주고 게임이름과 함께 에뮬레이터를 실행한다. 그리고 매 프레임마다 호출되는 update_callback 에서는 넘겨받은 이미지 프레임 버퍼를 미리 열어둔 파일에 오프셋 0부터 덮어쓴다.

...

#include "headless.h"

using namespace std;

mame_t *s_mame;
FILE *raw_img_buf_file;

void update_callback(void *data, mame_frame_t frame)
{
  fseek(raw_img_buf_file, 0, SEEK_SET);
  fwrite(frame.buffer, frame.buf_size, 1, raw_img_buf_file);
}

int main(int argc, char *argv[])
{
  // parsing options
  ...

  // open file
  raw_img_buf_file = fopen(f.c_str(), "wb");   // ignore close

  ...

  s_mame = get_mame_instance();
  s_mame->set_frame_info(width, height);
  s_mame->set_frame_cb(s_mame, update_callback);

  return s_mame->run("dino");
}

이렇게 이미지 프레임 데이터를 파일로 뽑아낼 수 있었고,

이 파일로 부터 읽은 이미지 프레임 스냅샷 한장을 열어보니.. 이럴수가 진짜 나온다. 야호! (Pixel 배열이 BGRA이라 온라인 컨버터로 변환해보니 잘 나옴)

 

WebRTC 스트리밍

이제 WebRTC 를 통해 클라이언트에 동영상을 전송하고 키 입력을 받아오는 간단한 웹서버를 만들어볼 차례다. 이 부분은 원재씨의 캐리가 있었다. 주요 부분만 언급해보자면,

 

WebRTC 시그널링 - 이 부분은 WebRTC의 세션을 맺기 위한 프로토콜 영역으로 P2P 간의 Endpoint(IP) 교환, 서로의 미디어타입 교환 등이 있게 되는데 이건 후에 따로 다뤄보도록 한다. 참고로 golang 기반에서는 pion 이라는 오픈소스를 이용했다.

 

이미지 프레임 읽기 - 위의 콘솔앱을 통해 만들어진 frames.raw 를 읽는 건 쓸때와 마찬가지로 파일을 열고 오프셋을 0으로 변경경한 후 버퍼사이즈만큼 읽고 다시 오프셋을 0으로 변경하고 읽고하면 될까? 물론 그래도 되지만 그럼 매번 read 를 통해 userspace 버퍼 복사가 일어나게 되어 비효율적이다. 이때 쓸만한게 mmap 이다. 이걸 이용하면 userspace 버퍼에(virtual address) kernelspace page (파일에 대한)을 직접 매핑해둠으로써 불필요한 복사를 줄일 수 있다.

type FrameBuffer interface {
  Open(path string) error
  Close()
  GetBuffer() []byte
}

type MemMappedBuffer struct {
  buffer     []byte
}

func (b *MemMappedBuffer) Open(path string) error {
  f, err := os.Open(path)
  if err != nil {
    return err
  }

  stat, err := f.Stat()
  if err != nil {
    return err
  }

  fileSize := stat.Size()
  if fileSize != frameSize {
    return errors.New("fb file size is not matched to the requested frame size")
  }
  
  mapped, err := syscall.Mmap(
    int(f.Fd()),
    0,
    int(fileSize),
    syscall.PROT_READ,
    syscall.MAP_SHARED)

  if err != nil {
    return err
  }

  b.buffer = mapped

  return nil
}

func (b *MemMappedBuffer) Close() {
  syscall.Munmap(b.buffer)
}

func (b *MemMappedBuffer) GetBuffer() []byte {
  return b.buffer
}

인코딩 - mmap 를 이용해 매핑된 버퍼를 읽은 후 이를 인코더를 통해 인코딩된 프레임을 만들어 클라이언트에 전송한다. 대충 vp8이나 h.264를 쓰려고 했는데 사용하기 쉽게 구현된 오픈소스(go-yuv2webRTC) 를 하나 발견해서 이걸 썼다. goroutine 을 두개 띄울건데 하나(encodeFrames)는 raw 프레임을 읽어서 인코더의 Input 채널로 넣는 일을 하고 다른 하나(sendEncodedFrames)는 인코더의 Output 채널을 컨슈밍하다가 인코딩된 프레임을 WebRTC의 비디오 트랙에 넘겨주는 일을 하게 된다. raw 프레임은 BGRA 컬러포맷이라 인코더에 넣기 위해서는 YUV로의 변환이 필요하다.  

...

import (
  ...
  "github.com/pion/webrtc/v2"
  "github.com/pion/webrtc/v2/pkg/media"
  vpxEncoder "github.com/poi5305/go-yuv2webRTC/vpx-encoder"
)

type WebRTCRenderer struct {
  ...
  frameBuffer   utils.FrameBuffer
  videoEncoder  *vpxEncoder.VpxEncoder
  videoTrack    *webrtc.Track
  audioTrack    *webrtc.Track
}

func (r *WebRTCRenderer) Start() {
  go r.encodeFrames()
  go r.sendEncodedFrames()
}

func (r *WebRTCRenderer) encodeFrames() {
  fileTicker := time.NewTicker(tickTime)  // 1/fps second

  defer func() {
    fileTicker.Stop()
  }()

  for {
    select {
    case <-fileTicker.C:
      buf := r.frameBuffer.GetBuffer()
      if buf != nil {
        yuv := utils.BGRATOYUV(buf, width, height)
        r.videoEncoder.Input <- yuv
      }
    }
  }
}

func (r *WebRTCRenderer) sendEncodedFrames() {
  for {
    encoded := <-r.videoEncoder.Output
    
    r.videoTrack.WriteSample(media.Sample{Data: encoded: Samples: 1})
  }
}

func NewWebRTCRenderer() *WebRTCRenderer {
  ...  
  fb := utils.MemMappedBuffer{}
  fb.Open("frames.raw")
  
  videoEncoder, _ := vpxEncoder.NewVpxEncoder(width, height, fps, 1200, 15)
  
  codec := webrtc.NewRTPVP8Codec(webrtc.DefaultPayloadTypeVP8, 90000)
  videoTrack, _ := webrtc.NewTrack(webrtc.DefaultPayloadTypeVP8, rand.Uint32(), "video", "pion2", codec)
  
  r := WebRTCRenderer{
    ...
    frameBuffer: fb,
    videoEncoder: videoEncoder,
    videoTrack: videoTrack,
  }
  
  return &r
}

 

결론

일단 이 프로토타이핑을 통해 에뮬레이터 + 인코딩 + WebRTC 스트리밍을 통해 게임 플레이가 될 수 있다는걸 확인했다. 여기에 다 적기는 어려웠지만 각 스텝마다 정말 많은 삽질이 있었다. 그런 디테일까지 못 담은게 좀 아쉽긴 하지만 '이런식으로 했구만'이라는 전반적인 느낌(?)이라도 잘 전달되길 기대해본다. 이제 좀 더 서비스 가능한 수준의 구현으로 넘어가 보겠습니다.