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

추억의 오락실 (10/11) - 기판과 비디오인코딩

gamz 2021. 2. 14. 01:04

이번에 다룰 컴포넌트는 오락실 오락기의 코어에 가깝다고 할 수 있는 기판이다. 기판을 뜯어보면서 어떤식으로 동작하는지 살펴보고 그 중에서도 비디오 인코딩(오디오 인코딩은 파라미터나 고려할 것들이 비디오에 비해 상대적으로 제한적이라 생략) 관련 부분을 좀더 자세히 살펴보고자 한다. 

 

아키텍처

 

위 그림은 기판의 쓰레드 구성, 의존하는 모듈, 제어 및 데이터의 흐름을 한곳에 담아보려고 그려보았다. 부연을 해보자면,

 

  • 쓰레드는 크게 네가지가 있다.

    • 메인 쓰레드 - 롬(Rom)매니저를 통해 필요한 게임롬들을 다운로드하고 프레임콜백을 등록하는 등의 초기화를 수행한 후에 에뮬레이터를 실해시킨다. 프레임콜백이 호출되면 채널을 통해 인코더쓰레드에게 프레임 데이터를 전달한다.

    • 커맨드 처리 쓰레드 - IPC로부터 입력된 Key 데이터를 읽어와서 libemu에 맞는 포맷으로 변경하여 에뮬레이터에게 전달한다.

    • 인코딩 쓰레드 - 에뮬레이터로부터 받아온 비디오/오디오 프레임 데이터를 각각 h264/opus 로 인코딩한 후 채널을 통해 패킷 전송 쓰레드에게 전달한다.

    • 데이터 전송 쓰레드 - 받아온 인코딩된 데이터를 IPC로 내보낸다.

  • 커맨드(키입력) 데이터는 IPC(via nanomsg)로 부터 read 해서 libemu를 통해 에뮬레이터(mame)로 넘긴다.

  • 비디오/오디오 데이터는 libemu에 등록한 프레임콜백으로부터 가져와서 libenc 를 통해 인코딩한 후 IPC(via nanomsg)로 전송한다.

  • 에뮬레이터 raw frame 데이터 -> 인코더 쓰레드 혹은 인코딩된 데이터 -> IPC 전송 쓰레드간의 데이터 전달은 채널(channel)을 이용한다. 즉, 쓰레드는 채널로 들어오는 데이터를 수신하고 있다가 데이터가 들어오면 그때 깨어나서 할일을 처리한다.

 

주요 기능 구현 요약

초기화

게임 롬 데이터 다운로드, 에뮬레이터 인스턴스 초기화, 채널 생성 및 콜백 등록 등을 처리한다.

mod roms;

use std::{env};
use crossbeam_channel as channel;

use libemu::Emulator;
use libenc::Encoder;
use crate::roms::RomManager;

...

const CHANNEL_BUF_SIZE: usize = 64;

fn main() {
    // 게임명, 해상도, IPC 경로 등 실행 인자로 넘어오는 값들 파싱
    let args: Vec<String> = env::args().collect();
    let props = extract_properties_from_args(&args);

    // 게임롬 리파지토리로부터 롬파일들을 가져옴 - AWS S3 활용 중
    let mut rom_manager = roms::AwsRomManager::create("./roms");
    rom_manager.pull_roms("mame", &props.system_name).unwrap();

    // 에뮬레이터(MAME) 인스턴스를 생성
    let mut emu = libemu::MameEmulator::create(
        props.resolution.w,
        props.resolution.h,
        props.fps);

    // RAW(bgra) 데이터 -> 인코딩 쓰레드의 데이터 흐름을 위한 채널 생성
    let (img_enc_tx, img_enc_rx) = channel::bounded(CHANNEL_BUF_SIZE);
    
    // Encoded 데이터 -> 프레임전송 쓰레드의 데이터 흐름을 위한 채널 생성
    let (img_frame_tx, img_frame_rx) = channel::bounded(CHANNEL_BUF_SIZE);
 
    // 에뮬레이터에 프레임콜백 등록 - 인코딩 채널로 바로 꽂아주는 람다 콜백
    emu.set_image_frame_cb(|f: libemu::EmuImageFrame| { img_enc_tx.send(f).unwrap(); });
    
    // 인코딩 쓰레드와 프레임전송 쓰레드를 각각 실행
    run_frame_encoder(&props, img_enc_rx, img_frame_tx);
    run_frame_handler(&props, img_frame_rx);

    ...
    
    // 키 입력 쓰레드도 실행
    run_cmd_handler(&props, emu.clone());

    // 게임 실행 - 블럭킹
    emu.run(&props.system_name);
}

 

비디오 데이터 출력

위에서도 살짝 언급한 프레임 콜백(람다)이 불리고 이때 넘겨받은 프레임 데이터를 인코딩하고 IPC로 출력하는 과정에 대한 코드를 walk through 해보자.

fn main() {
    ...
    
    let (img_enc_tx, img_enc_rx) = channel::bounded(CHANNEL_BUF_SIZE);
    let (img_frame_tx, img_frame_rx) = channel::bounded(CHANNEL_BUF_SIZE);
 
    // 1. 프레임콜백에서 RAW 이미지를 인코딩 채널 tx를 통해 큐잉
    emu.set_image_frame_cb(|f: libemu::EmuImageFrame| { img_enc_tx.send(f).unwrap(); });

    // 인코딩 쓰레드는 img_enc_rx로 부터 수신해서 img_frame_tx로 전송해야하기 때문에 둘 모두를 인자로 받음
    run_frame_encoder(&props, img_enc_rx, img_frame_tx);
    
    // 프레임전송 쓰레드는 인코딩쓰레드가 넘겨주는 데이터만 수신하면되기 때문에 rx채널만 받음
    run_frame_handler(&props, img_frame_rx);
    ...
}

fn run_frame_encoder(
    props: &GameProperties,
    encoder_rx: channel::Receiver<libemu::EmuImageFrame>,
    frame_tx: channel::Sender<libenc::EncodedFrame>) {

    let mut vid_enc = libenc::H264Encoder::create(
        props.resolution.w,
        props.resolution.h,
        props.fps,
        props.keyframe_interval);
    
    thread::spawn(move || {
        loop {
            // 2. 프레임콜백에서 큐잉한 Raw 프레임을 수신해서 인코딩 후에 frame 채널tx 로 전송함
            let raw_frame = encoder_rx.recv().unwrap();
            let frame = libenc::VideoFrame::from(&raw_frame.buf, raw_frame.timestamp);
            if let Ok(encoded) = vid_enc.encode_video(&frame) {
                frame_tx.send(encoded).unwrap();
            }
        }
    });
}

fn run_frame_handler(
    props: &GameProperties,
    frame_rx: channel::Receiver<libenc::EncodedFrame>) {
        
    let frame_output_path = String::from(&props.imageframe_output);

    thread::spawn(move || {
        let mut socket = Socket::new(Protocol::Push).unwrap();
        socket.set_send_buffer_size(4096 * 1024).unwrap();
        socket.bind(&frame_output_path).unwrap();

        loop {
            // 3. 그럼 여기서 인코딩된 데이터를 받고 이를 nanomsg socket(IPC)을 이용해 전송
            let frame = frame_rx.recv().unwrap();
            socket.write_all(frame.buf.as_ref()).unwrap();
        }
    });
}

 

키 입력

이번엔 IPC를 통해 들어오는 키입력이 어떻게 에뮬레이터(마메)로 전달되는지 살펴보자.

fn main() {
    let args: Vec<String> = env::args().collect();
    let props = extract_properties_from_args(&args);
    ...
    
    let mut emu = libemu::MameEmulator::create(
        props.resolution.w,
        props.resolution.h,
        props.fps);
    ...
    
    run_cmd_handler(&props, emu.clone());
}

#[derive(Deserialize, Debug)]
struct Command {
  cmd: String,
  args: Vec<String>,
}

fn run_cmd_handler(
    props: &GameProperties,
    emu: (impl libemu::Emulator + Send + 'static)) {

    let cmd_input_path = String::from(&props.cmd_input);

    thread::spawn(move || {
        // 게임 컨트롤 키에 대한 핸들러
        let handle_cmd_key = |args: &Vec<String>| {
            emu.put_input_event(libemu::EmuInputEvent {
                ... // from args
            });
        };

        // 게임 제어 명령에 대한 핸들러
        let handle_cmd_ctrl = |args: &Vec<String>| {
            let ctrl_val = &args[0];
            match &ctrl_val[..] {
                "pause" => emu.pause(),
                "resume" => emu.resume(),
                "shutdown" => process::exit(0),
                _ => println!("ctrl val: {}", &args[0]),
            }
        };

        // IPC로 부터 읽어올 소켓이기 때문에 Pull 모드로 생성
        let mut socket = Socket::new(Protocol::Pull).unwrap();
        socket.bind(&cmd_input_path).unwrap();

        let mut buf = [0u8; 1024];
        loop {
            // IPC socket 으로부터 바이트를 읽어와서 Command로 deserialize한 후 핸들링
            let bytes_read = socket.read(&mut buf).unwrap();
            let json_str = str::from_utf8(&buf[0..bytes_read]).unwrap();
            let command: Command = serde_json::from_str(&json_str).unwrap();

            match &command.cmd[..] {
                "key" => handle_cmd_key(&command.args),
                "ctrl" => handle_cmd_ctrl(&command.args),
                _ => println!("not supported cmd"),
            }
        };
    });
}

 

비디오 인코딩

비디오 인코딩하는 부분을 조금 더 자세히 살펴보자. (참고로 코덱으로 사용한 라이브러리는 libx264이고 이것의 rust wrapper(binding)으로는 rust-av/x264-rs 를 썼다.)

 

다시 run_frame_encoder 에서부터 시작해본다. Thread loop을 돌기 전에 인코더 인스턴스를 하나 생성하고 loop에서는 매 Raw 프레임 마다 이 인코더 인스턴스의 encode_video()를 호출해 인코딩을 수행한다.

fn run_frame_encoder(...) {
    // 인코더(H264) 인스턴스를 생성하고
    let mut vid_enc = libenc::H264Encoder::create(
        props.resolution.w,
        props.resolution.h,
        props.fps,
        props.keyframe_interval);
    
    thread::spawn(move || {
        loop {
            let raw_frame = encoder_rx.recv().unwrap();   

            // 수신한 RAW 프레임데이터를 VideoFrame 형으로 만들어서 encode_video() 를 호출
            let frame = libenc::VideoFrame::from(&raw_frame.buf, raw_frame.timestamp);
            if let Ok(encoded) = vid_enc.encode_video(&frame) {
                frame_tx.send(encoded).unwrap();
            }
        }
    });
}

그럼 먼저 인코더 인스턴스 생성하는 부분으로 가보자. H264Encoder::create() 내부적으로 x264 인코더 파라미터들을 초기화하고 이 파라미터를 이용해 x264 인코더 인스턴스를 하나 생성한다. 

pub trait Encoder {
    fn encode_video(&mut self, frame: &VideoFrame) -> Result<EncodedFrame, String>;
    fn encode_audio(&mut self, frame: &AudioFrame) -> Result<EncodedFrame, String>;
}

pub struct H264Encoder {
    w: usize,
    h: usize,
    fps: usize,
    keyframe_interval: usize,

    enc_params: x264::Param,
    enc_ctx: x264::Encoder,

    frame_index: 0    
}

impl H264Encoder {
    pub fn create(w: usize, h: usize, fps: usize, keyframe_interval: usize) -> impl Encoder {
        let mut enc_params = H264Encoder::create_enc_params(w, h, keyframe_interval);
        let enc_ctx = x264::Encoder::open(&mut enc_params).unwrap();

        H264Encoder {
            w: w,
            h: h,
            fps: fps,
            keyframe_interval: keyframe_interval,
            enc_params: enc_params,
            enc_ctx: enc_ctx,
            frame_index: 0
        }
    }

    fn create_enc_params(w: usize, h: usize, kf_interval: usize) -> x264::Param {
        x264::Param::new()
            .set_dimension(h, w)
            .param_parse("keyint", &kf_interval.to_string()).unwrap()
            .param_parse("min-keyint", &kf_interval.to_string()).unwrap()
            ...
            ...
            .apply_profile("baseline").unwrap()
    }
}
            

이제 encode_video() 로 가보자. 이 메소드는 Encoder 라는 trait에 정의되어 있는데 H264Encoder도 이 trait 을 구현해야 하한다. encode_audio 는 해당 사항이 없기 때문에 unimplemented!() 처리했고, encode_video에서는 일단 RAW프레임의 color인 BGRA를 YUV420으로 변환한 후 x264 인코더 인스턴스를 이용해 인코딩을 하고 결과를 반환한다.

impl Encoder for H264Encoder {
    fn encode_audio(&mut self, _frame: &AudioFrame) -> Result<EncodedFrame, String> {
        unimplemented!();
    }

    fn encode_video(&mut self, frame: &VideoFrame) -> Result<EncodedFrame, String> {
        // RAW 프레임의 color인 BGRA에서 인코더가 받는 포맷인 YUV420으로 변환
        let yuv_size = self.w * self.h;
        let chroma_size = yuv_size / 4;
        let mut y = vec![0u8; yuv_size];
        let mut u = vec![0u8; chroma_size];
        let mut v = vec![0u8; chroma_size];
        utils::converter::bgra_to_yuv420(self.w, self.h, &frame.buf, &mut y, &mut u, &mut v);

        // x264의 Picture 인스턴스를 하나 생성하고 이것의 버퍼에 YUV420 데이터를 복사 
        let mut pic = x264::Picture::from_param(&self.enc_params).unwrap()
            .set_timestamp(self.frame_index);
        self.frame_index += 1;

        unsafe {
            ptr::copy(y.as_ptr(), pic.as_mut_slice(0).unwrap().as_mut_ptr(), yuv_size);
            ptr::copy(u.as_ptr(), pic.as_mut_slice(1).unwrap().as_mut_ptr(), chroma_size);
            ptr::copy(v.as_ptr(), pic.as_mut_slice(2).unwrap().as_mut_ptr(), chroma_size);
        };

        // x264 인코더를 이용해 인코딩 수행
        match self.enc_ctx.encode(&pic) {
            Ok(Some((nal, _, _))) => {
                // 인코딩이 성공하면 EncodedFrame으로 반환 
                let encoded = Vec::from(nal.as_bytes());
                Ok(EncodedFrame { buf: encoded, timestamp: frame.timestamp, })
            },
            ...
        }
    }
}

 

Low Latency 인코딩

동영상 인코딩(lossy)의 성능관점에서 주요한 요소가 세가지가 있다.

  • 얼마나 빨리 인코딩할 수 있는가 (Speed)

  • 결과물이 얼마나 좋은 품질인가 (Quality)

  • 결과물이 얼마나 압축이 잘 되었는가 (File Size or Compression Rate)

 

 

이 세가지 요소들은 삼각 Trade-off 관계에 있는데, 가령 이런식이다.

  • 빠른 인코딩 속도와 괜찮은 품질을 뽑아내려면 파일사이즈를 줄일 수 (혹은 압축률을 늘릴 수) 없다.

  • 빠른 인코딩과 더 좋은 압축률을 원한다면 품질이 떨어진다.

  • 좋은 압축률과 좋은 품질의 결과물을 원한다면 아주 느린 인코딩 속도를 감수해야한다.

그런 관점에서 오락실(같은 스트리밍 게이밍 서비스)이 처한 상황은 좀 극한이다.

  • 게임이라 실시간 인터랙션이 필요하기 때문에 인코딩 속도가 빨라야한다.

  • 네트워크 대역폭(레이턴시에도 영향) 및 비용 이슈를 생각하면 파일사이즈는 작을 수록 좋다.

  • 프레임 품질은 게임에 방해가 안되도록 괜찮게 나와야한다.

오락실은 레트로 게임을 대상으로 하기 때문에 애초에 화질을 자랑하는 게임들이 아니라 품질 부분을 조금 희생해서 다른 요소를 조금이라도 챙겨볼 수 있겠지만 요즘 나오는 게임들 처럼 Full HD 혹은 4k 의 고해상도 게임을 스트리밍으로 제공하는 서비스들은 많은 것들이 고려되어야할 것 같다. (일반 컴퓨팅으로는 어려울 것 같고 GPU Farm을 쓰든지 좀더 최적화된 ASIC을 만들어 쓰든지 그러지 않을까? 궁금하다.. 아시는 분은 댓글 좀 달아주세요.)   

 

인코딩 파라미터

libx264는 인코딩 속도를 기준으로한 설정(preset)과 비디오의 종류 혹은 유스케이스에 따른 설정(tune)을 제공한다. (참고

[Preset]
ultrafast
superfast
veryfast
faster
fast
medium – default preset
slow
slower
veryslow

[Tune]
film – use for high quality movie content; lowers deblocking
animation – good for cartoons; uses higher deblocking and more reference frames
grain – preserves the grain structure in old, grainy film material
stillimage – good for slideshow-like content
fastdecode – allows faster decoding by disabling certain filters
zerolatency – good for fast encoding and low-latency streaming
psnr – ignore this as it is only used for codec development
ssim – ignore this as it is only used for codec development

가령, Preset의 ultrafast는 빠른 인코딩 속도를 제공하는 반면 품질을 보장하긴 어려운 반면 veryslow 의 경우는 품질을 유지하면서도 좋은 압축률로 bitrate 를 많이 아낄 수 있다. Tune 은 비디오의 성격(같은 색의 영역이 많은 카툰이나 슬라이드쇼같은 정지 영상같은 느낌 혹은 영화같은 실사)이나 사용하는 상황(빠른 디코딩이나 인코딩이 필요한 경우 등)에 따라 미리 정의된 설정을 제공한다.

 

오락실은 무조건 속도에 포커싱을 맞추고자 Preset / Tune 설정을 각각 "ultrafast" / "zerolatency" 로 했다. 코드로는 매우 간단한데 해당 Preset / Tune 설정 값으로 코덱 파라미터를 만들면 된다.

fn create_enc_params(w: usize, h: usize, kf_interval: usize) -> x264::Param {
    x264::Param::default_preset("zerolatency", "ultrafast").unwrap()
}

그런데 구체적으로 어떤 코덱 파라미터들이 미리 정의되는걸까. x264의 커맨드 라인 인코딩 옵션을 보면 아래처럼 Preset / Tune 마다 상세 파라미터들이 어떻게 설정되어있는지 나온다.

--preset        Use a preset to select encoding settings [medium]
                  Overridden by user settings.
                  - ultrafast:
                    --no-8x8dct --aq-mode 0 --b-adapt 0
                    --bframes 0 --no-cabac --no-deblock
                    --no-mbtree --me dia --no-mixed-refs
                    --partitions none --ref 1 --scenecut 0
                    --subme 0 --trellis 0 --no-weightb
                    --weightp 0
                            
--tune          Tune the settings for a particular type of source or situation
                  Overridden by user settings.
                  Multiple tunings are separated by commas.
                  Only one psy tuning can be used at a time.
                  - zerolatency:
                    --bframes 0 --force-cfr --rc-lookahead 0
                    --sync-lookahead 0 --sliced-threads

이걸 참고하면, 코드상에서는 아래 각 파라미터들을 수동으로 줄 수도 있다.

fn create_enc_params(w: usize, h: usize, kf_interval: usize) -> x264::Param {
    x264::Param::new()
        .set_dimension(h, w)
        .param_parse("keyint", &kf_interval.to_string()).unwrap()
        .param_parse("min-keyint", &kf_interval.to_string()).unwrap()

        // - manual preset params for "ultrafast"
        .param_parse("bframes", "0").unwrap()
        .param_parse("aq-mode", "0").unwrap()
        .param_parse("b-adapt", "0").unwrap()
        .param_parse("no-8x8dct", "1").unwrap()
        .param_parse("no-cabac", "1").unwrap()
        .param_parse("no-deblock", "1").unwrap()
        .param_parse("no-mbtree", "1").unwrap()
        .param_parse("no-mixed-refs", "1").unwrap()
        .param_parse("no-weightb", "1").unwrap()
        .param_parse("partitions", "none").unwrap()
        .param_parse("rc-lookahead", "0").unwrap()
        .param_parse("ref", "1").unwrap()
        .param_parse("scenecut", "0").unwrap()
        .param_parse("trellis", "0").unwrap()
        .param_parse("me", "dia").unwrap()
        .param_parse("subme", "0").unwrap()
        .param_parse("weightp", "0").unwrap()

        // - manual tune params for "zerolatency"
        .param_parse("bframes", "0").unwrap()
        .param_parse("rc-lookahead", "0").unwrap()
        .param_parse("sync-lookahead", "0").unwrap()
        .param_parse("sliced-threads", "1").unwrap()
        .param_parse("no-mbtree", "1").unwrap()
        .param_parse("force-cfr", "1").unwrap()

        ...
        .apply_profile("baseline").unwrap()
}

사실 각 파라미터들이 하나하나 정확히 어떤 기능을 의미하는지는 잘 모르지만 (하나하나 천천히 공부해보면서 업데이트 해보겠습니다.) 대부분 some-feature 플래그는 거의 0 이고 no-some-feature 플래그는 1 로 되어있는걸 볼 수 있다. 거의 많은 코덱 피쳐들을 안쓰는걸로 인코딩 속도를 확보하는 것 같다.

 

정리

<캐딜락 & 다이노소어>를 아래와 같은 설정으로 5분 정도 플레이 해봤다.

  • resolution 640x480, fps 25

  • keyframe interval 200 (사이즈 줄여보겠다고 늘려봄)

  • x264 with ultrafast / zerolatency

전송되는 비디오 데이터의 Bitrate은 0.6Mbits/s 정도 5분간 대략 20MB 정도 사용되었고 플레이상 지연감은 못 느꼈다.

 

 

플레이 영상