← 과제 목록

[과제] 3주차) ThorVG 활용사례 조사 이정민

@mungjin01
  • #과제

무엇을 조사할까 쭉 둘러보던 중 무언가 익숙한 ui가 보여 Metamodule을 선택했다. 신디를 다뤄본 경험은 없지만 daw 등으로 친숙한 화면들과 개인 멀티 이펙터 다룰 때 하드웨어 기기에서 ui가 표시되는 것이 신기했던 기억이 갑자기 떠올라 주제를 Metamodule로 잡았다.
images
metamodule 안에서 ThorVG를 빌드하는 설정을 보기 위해 firmware/lib/thorvg/CMakeLists.txt 를 열면 이렇게 있다.

   thorvg/src/renderer/tvgSwCanvas.cpp
   # thorvg/src/renderer/tvgGlCanvas.cpp
   # thorvg/src/renderer/tvgWgCanvas.cpp
   
   thorvg/src/loaders/raw/tvgRawLoader.cpp

firmware/lib/thorvg/CMakeLists.txt를 보면 GPU 렌더러인 OpenGL, WebGPU 코드는 제외하고 소프트웨어 래스터라이저만 포함되어 있다.

로더도 raw만 남아 있고 SVG, PNG, JPG, Lottie, TTF 로더는 모두 빠져 있다. config.h에서도 THORVG_SW_RASTER_SUPPORT만 활성화되어 있다.

즉, metamodule에서는 ThorVG를 파일 렌더러나 Lottie 플레이어가 아니라 소프트웨어 기반 그래픽 처리 용도로만 사용한다.

VCV Rack(컴퓨터 안에서 모듈러 신시사이저를 구성하는 프로그램)에서 패널 배경을 png로 미리 만들어두고 싶어도 그럴 수가 없는 것이

  • 오실로스코프의 파형
  • 시퀀서의 재생 위치
  • 실시간 레벨 미터
  • 플러그인 내부의 작은 디스플레이

이런 화면은 현재 데이터를 기준으로 매 순간 다시 그려야 한다. 이러한 부분을 위해 ThorVG가 사용되고 있다.
예를 들어

firmware/coreproc_plugin/graphics/waveform_display.cc는 오디오 데이터를 선으로 바꾸는 코드가 인데

for (unsigned int x = 0; x < samples.size(); x++) {
    float x_pos =
        static_cast<float>(x) * display_width /
        static_cast<float>(samples.size());

    float y_pos =
        samples[i].first * wave_height + wave_height;

    if (x == 0)
        internal->wave->moveTo(x_pos, y_pos);
    else
        internal->wave->lineTo(x_pos, y_pos);

    i = (i + 1) % samples.size();
}

각 샘플 값을 화면의 x, y 좌표로 바꾼다. 이후 moveTo()lineTo()로 점을 연결해 파형을 만든다.

VCV Rack 플러그인은 위젯을 그릴 때 NanoVG(OpenGL 위에서 도는 작은 벡터 그래픽스 라이브러리로, API가 HTML5 Canvas와 거의 같음)를 쓴다.

void drawLayer(const DrawArgs& args, int layer) override {
    nvgBeginPath(args.vg);
    nvgMoveTo(args.vg, 0, 10);
    ...
}

Rack 플러그인이 전부 이런 방식으로 NanoVG를 사용하고 있는데, MetaModule 하드웨어인 STM32MP1에는 GPU가 없다. 플러그인 코드를 수정할 수도 없다면, 플러그인이 호출하는 NanoVG API는 그대로 유지하면서 그 뒤의 렌더링 방식만 바꿔야 한다.

NanoVG는 크게 두 부분으로 나뉜다. 앞쪽에서는 nvgCircle()이나 nvgRect() 같은 명령을 받아 도형의 좌표와 경로를 계산한다. 뒤쪽에서는 계산된 결과를 실제 화면의 픽셀로 그린다.

이 뒤쪽 렌더러는 교체할 수 있게 만들어져 있다. NanoVG 컨텍스트를 생성할 때 NVGparams 구조체에 렌더링 함수들을 등록하면, 이후 NanoVG는 도형이나 텍스트를 그릴 때 등록된 함수를 호출한다.

NVGcontext *
nvgCreatePixelBufferContext(void *canvas, std::span<uint32_t> buffer,
                            uint32_t buffer_width, uint32_t px_per_3U) {
    NVGparams params;
    memset(&params, 0, sizeof(params));

    params.renderFill      = renderFill;
    params.renderStroke    = renderStroke;
    params.renderText      = renderText;
    params.renderTriangles = renderTriangles;
    ...
    params.edgeAntiAlias = 0;

    return nvgCreateInternal(&params, nullptr);
}

플러그인에서는 이전과 똑같이 nvgCircle()을 호출한다. NanoVG 앞쪽에서는 원의 경로를 계산하고, 마지막에는 params.renderFill에 등록된 함수를 호출한다. MetaModule에서는 이 함수가 ThorVG를 사용해 원을 CPU로 그린다.

따라서 플러그인은 자신이 GPU용 NanoVG 렌더러를 사용하고 있는지, ThorVG가 CPU로 그리고 있는지 알지 못한다.

다만 NanoVG의 모든 작업을 ThorVG 하나로 넘긴 것은 아니라고 한다. NVGparams에 등록하는 함수들은 채우기, 선 그리기, 텍스트, 이미지처럼 작업 종류별로 나뉘어 있다. MetaModule 구현에서는 각 작업을 환경에 맞는 방식으로 따로 처리한다.

renderFillrenderStroke는 ThorVG를 사용하는데 renderFill은 사각형이나 원처럼 내부가 채워진 도형을 그리고, renderStroke는 선이나 도형의 테두리를 그린다.

예를 들어 플러그인이 다음과 같이 호출하면,

nvgBeginPath(vg);
nvgCircle(vg, x, y, radius);
nvgFill(vg);

NanoVG가 원의 경로를 계산한 뒤 renderFill을 호출하고, renderFill 내부에서 ThorVG가 해당 경로를 픽셀 버퍼에 그린다.

ThorVG 캔버스를 생성하는 부분은 nanovg_pixbuf_drawctx.hh에 있다.

DrawContext(lv_obj_t *canvas, std::span<uint32_t> buff, uint32_t width)
    : canvas{canvas} {
    tvg_canvas = tvg::SwCanvas::gen();

    tvg_canvas->target(
        buff.data(),
        width,
        width,
        buff.size() / width,
        tvg::ColorSpace::ARGB8888
    );
}

여기서 중요한 부분은 target()에 전달되는 buff 인데 이게 뭐냐면 바깥에서 전달받은 LVGL 캔버스의 픽셀 버퍼다. ThorVG는 별도의 화면 이미지를 만든 뒤 LVGL로 복사하지 않고, LVGL이 최종적으로 표시할 메모리에 직접 그린다.

LVGL 캔버스 버퍼

ThorVG SwCanvas가 직접 기록

따라서 ThorVG와 LVGL은 서로 다른 이미지 버퍼를 사용하는 것이 아니다. ThorVG가 LVGL 소유의 메모리를 렌더링 대상으로 빌려 쓰는 구조라고 이해할 수 있다.

이 방식에는 별도의 복사 과정이 필요 없이 ThorVG가 그리기를 끝내면 LVGL은 이미 결과가 들어 있는 같은 버퍼를 화면에 표시하면 된다.

MetaModule에서 ThorVG를 사용한 목적은 SVG 파일이나 Lottie 애니메이션을 재생하기 위해서라기보다는

  • NanoVG가 계산한 도형을 받을 수 있어야 한다.
  • GPU 없이 CPU로 렌더링할 수 있어야 한다.
  • 자체 버퍼가 아니라 LVGL의 외부 버퍼에 직접 그릴 수 있어야 한다.

이 정도가 다라고 한다.

tvg::Initializer::init(0, tvg::CanvasEngine::Sw);

canvas->target(
    external_buffer,
    stride,
    width,
    height,
    tvg::ColorSpace::ARGB8888
);

canvas->push(scene);
canvas->draw();
canvas->sync();
canvas->remove();

CanvasEngine::Sw로 소프트웨어 렌더러를 초기화하고, target()으로 LVGL의 버퍼를 연결한다. 이후 도형을 캔버스에 넣고 그린 다음, 작업이 끝나면 캔버스에서 제거한다.
이 과정은 화면 전체를 구성한 뒤 프레임 마지막에 한 번 실행되는 구조가 아니다. 현재 구현에서는 renderFill이나 renderStroke가 처리하는 도형 단위로 push(), draw(), sync(), remove()가 반복된다.

즉 도형 하나가 들어올 때마다 다음 작업이 수행된다.

도형 생성
→ 캔버스에 추가
→ 소프트웨어 렌더링
→ 완료 대기
→ 캔버스에서 제거

NanoVG의 GPU 백엔드를 완전히 다시 만든 것이 아니라, NanoVG가 넘겨주는 도형 하나씩을 ThorVG로 즉시 픽셀 버퍼에 굽는 방식이라고 이해하면 될 것 같다.

댓글

Discussion 원문