← 과제 목록

[과제] 3주차) ThorVG 활용사례 조사 임재현

@inmare
  • #과제
목차

Metamodule이란?

Eurorack이라는 모듈러 신디사이저에 넣을 수 있는 하드웨어 모듈의 한 종류로, 해당 모듈에서 VCV Rack이라는 PC프로그램에서 기존에 사용했던 가상 모듈 패치 여러개를 한번에 사용할 수 있게 해주는 역할을 한다. (이때 모듈이란, Oscillator나 Filter를 노브로 조정해서 사용자가 원하는 사운드를 만드는 역할을 해주는 하나의 구역이라고 보면 된다.) 해당 모듈을 사용할 때 모듈의 스크린에서 세부적인 설정들을 조절할 수 있는데, 이 설정들의 GUI를 표시하는데에 ThorVG가 사용된다.
또한, 이 하드웨어를 PC에서 시뮬레이션 하는데에도 GUI로 ThorVG가 사용되고 있다.

image

ThorVG를 선택한 이유

해당 프로젝트는 원래 NanoVG를 GUI로 사용했으나, Github issue(링크)에서 NanoVG대신 ThorVG를 선택한 이유를 확인할 수 있었다.
다각형 렌더링을 최적화하는데에 ThorVG를 사용했으며 이를 통해 concave polygon을 사용할 수 있게 되었음을 이슈에서 확인할 수 있었다.

프로젝트 구조

해당 프로젝트에서 ThorVG를 사용하는 부분은, 실제로 하드웨어에서 사용되는 firmware폴더와, 해당 하드웨어를 시뮬레이션하는 simulator부분이다.
그 중에서도 단순히 GUI를 렌더링하는 부분은 LVGL을 사용하고, 여러 종류의 도형을 렌더링하는데에 ThorVG를 사용하고 있었다.
이 글에서는 그 중에서 ThorVG를 직접적으로 사용하고 있는 firmware부분에 대해서 작성하였다.

firmware 내부의 구조

firmware에서 ThorVG를 사용하는 부분은 아래처럼 나타낼 수 있다.

firmware
  - lib
    - thorvg
      - CMakeLists.txt
      // 해당 폴더 내부에서 thorvg 라이브러리 import
  - coreproc_plugin
    - grphic
      - waveform_display.cc
      // 오디오의 waveform을 그리는 데에 thorvg를 활용
  - vcv_plugin
    - internal
      - nanovg_pixbuf_drawctx.hh
      - nanovg_pixbuf.cc
      // nanovg api는 유지하되, 내부에서는 polygon등을 그리는 데에 thorvg를 활용

CMakeLists.txt의 코드에서는 저성능의 임베디드 하드웨어에서 사용하기 위해 여러 불필요한 라이브러리들을 생략한 것을 확인할 수 있다.
또한 꼭 필요한 cpp, h파일들만 import해서 해당 하드웨어에서 불필요한 JPG, PNG 이미지로드, 그리고 SVG, Lottie파일 로드 등의 기능을 제외한 것을 확인할 수 있다.

# 필요한 라이브러리들만 import
target_include_directories(ThorVG PUBLIC
    ./
    thorvg/inc
    thorvg/src/common
    thorvg/src/renderer
    thorvg/src/renderer/sw_engine
	thorvg/src/loaders/raw
)

target_include_directories(ThorVG PRIVATE thorvg/src)

target_sources(ThorVG PRIVATE
   thorvg/src/common/tvgCompressor.cpp
   thorvg/src/common/tvgMath.cpp
   thorvg/src/common/tvgStr.cpp

   thorvg/src/renderer/tvgAccessor.cpp
   thorvg/src/renderer/tvgAnimation.cpp
   thorvg/src/renderer/tvgCanvas.cpp
   thorvg/src/renderer/tvgFill.cpp
   thorvg/src/renderer/sw_engine/tvgSwFill.cpp
   thorvg/src/renderer/sw_engine/tvgSwImage.cpp
   thorvg/src/renderer/sw_engine/tvgSwMath.cpp

   # 기타 필수 cpp파일들...
   # thorvg/src/renderer/tvgGlCanvas.cpp
   # thorvg/src/renderer/tvgWgCanvas.cpp
   # OpenGL, WebGPU 렌더러는 저수준의 하드웨어에서 불필요하기에 생략

   # 이미지 로드 파일도 raw파일만 가능하도록 설정함
   thorvg/src/loaders/raw/tvgRawLoader.cpp
)

저렇게 import된 thorvg모듈들은 nanovg_pixbuf_drawctx.hh에서 사용되고, 기존에 사용되던 nanovg대신 백엔드로 사용되어서 ui를 그리게 된다.

// nanovg_pixbuf_drawctx.hh
struct DrawContext {
	lv_obj_t *canvas{};
	lv_draw_label_dsc_t label_dsc{};

	std::vector<Texture> textures;

	std::vector<TextRenderCacheEntry> labels;

	uint32_t draw_frame_ctr{};

	unsigned px_per_3U = 240;

	tvg::SwCanvas *tvg_canvas{};

	DrawContext(lv_obj_t *canvas, std::span<uint32_t> buff, uint32_t width)
		: canvas{canvas} {

		lv_draw_label_dsc_init(&label_dsc);

        // UI에 대한 DrawContext 생성 시 ThorVG의 SwCanvas 생성
		tvg_canvas = tvg::SwCanvas::gen();
		tvg_canvas->target(buff.data(), width, width, buff.size() / width, tvg::ColorSpace::ARGB8888);
	}

	~DrawContext() {
		delete tvg_canvas;
	}
};

마찬가지로, nanovg_pixbuf.cc 파일에서 도형을 그릴 때 ThorVG를 사용해서 Shape를 생성하고, Fill과 Stroke를 적용하는 것을 확인할 수 있었다.

void renderFill(void *uptr,
				NVGpaint *paint,
				NVGcompositeOperationState compositeOperation,
				NVGscissor *scissor,
				float fringe,
				const float *bounds,
				const NVGpath *paths,
				int npaths) {

	// 중략

	for (auto &path : std::span{paths, (size_t)npaths}) {
		dump_draw("Fill path: #fill %d = count:%d\n", path.nfill, path.count);
		if (path.count < 3)
			continue;

		auto poly = tvg::Shape::gen();

		poly->moveTo(path.fill[0].x, path.fill[0].y);
		for (auto pt : std::span{path.fill + 1, (size_t)(path.count - 1)}) {
			poly->lineTo(pt.x, pt.y);
		}
		poly->close();

		auto [r, g, b, a] = to_tvg_color(paint->innerColor);
		poly->fill(r, g, b, a);

		// Clip/Scissor
		if (scissor->extent[0] >= 0 && scissor->extent[1] >= 0) {
			auto clip_region = tvg::Shape::gen();
			auto x = scissor->xform[4] - scissor->extent[0];
			auto y = scissor->xform[5] - scissor->extent[1];
			auto w = 2 * scissor->extent[0];
			auto h = 2 * scissor->extent[1];
			clip_region->appendRect(x, y, w, h);
			poly->clip(clip_region);
		}

		// Clipping에 적용할 Scene 생성
		auto scene = tvg::Scene::gen();
		scene->push(poly);
		scene->scale(scaling);

		context->tvg_canvas->push(scene);
		context->tvg_canvas->draw();
		context->tvg_canvas->sync();
		context->tvg_canvas->remove();
	}
}

void renderStroke(void *uptr,
				  NVGpaint *paint,
				  NVGcompositeOperationState compositeOperation,
				  NVGscissor *scissor,
				  float fringe,
				  float strokeWidth,
				  const NVGpath *paths,
				  int npaths) {

    // 중략

	for (auto &path : std::span{paths, (size_t)npaths}) {
		dump_draw("Stroke path: #strokes %d = count:%d + closed:%d\n", path.nstroke, path.count, path.closed);
		if (path.count < 2)
			continue;

        // ThorVG Shape 생성
		auto poly = tvg::Shape::gen();

		poly->moveTo(path.stroke[0].x, path.stroke[0].y);
		for (auto pt : std::span{path.stroke + 1, (size_t)(path.nstroke - 1)}) {
			poly->lineTo(pt.x, pt.y);
		}

		auto [r, g, b, a] = to_tvg_color(paint->innerColor);
		poly->strokeFill(r, g, b, a);

		float stroke_width = strokeWidth;
		if (scaling < 1) {
			constexpr float MinStroke = 1.3f;
			// Divide by /scaling so that when the rendering engine scales, it results in MinStroke
			stroke_width = std::max(mm_to_px(to_mm(strokeWidth), context->px_per_3U), MinStroke / scaling);
		}
		poly->strokeWidth(stroke_width);

		// 후략
	}
}

UI가 그려지는 흐름의 예시

이는 나중에 firmware 내부에서 gui를 사용할 때 여러 단계를 거쳐서 사용되게 된다.
이를 단순한 흐름으로 나타내자면 아래와 같은 흐름으로, 이 모듈이 VCV Rack이라는 프로그램에서 사용하던 모듈패치를 가져와서 자신의 하드웨어에서 플레이 하기 위해서 가상의 Rack을 만들고, 내부에서 NanoVG API를 호출하고, 그 내부에서 최종적으로 백엔드로 ThorVG를 사용하면서 그림이 그려지는 과정을 알 수 있다.

ModuleView / PatchView
  -> DynamicDisplayDrawer::draw()
  -> module->draw_graphic_display()          // MetaModule 쪽 가상 함수
  -> rack::engine::Module::draw_graphic_display()  // 가짜 Rack 구현
  -> widget->draw(args)  (NanoVG)
  -> nvgFill 등
  -> nanovg_pixbuf.cc::renderFill
  -> tvg::Shape / SwCanvas

그 예시로, ModuleView에서 매 프레임마다 UI가 그려지는 흐름을 정리해보았다.

  1. src/gui/pages/module_view/draw_module.cc에서 UI가 drawer를 호출한다.
if (dyn_draw_throttle && (++dyn_draw_throttle_ctr >= dyn_draw_throttle)) {
	dyn_draw_throttle_ctr = 0;
	prepare_dynamic_elements();
	dyn_draw.draw();
}
  1. drawer가 MetaModule 인터페이스를 통해서 모듈에 그리라고 명령한다.
// dyn_display_drawer.hh
if (module->draw_graphic_display(disp.id)) {
	if (copy_and_compare_buffer(disp.lv_buffer, disp.fullcolor_buffer))
		lv_obj_invalidate(disp.lv_canvas);
}
  1. MetaModule에서 구현한 가짜 Rack Module이 NanoVG 컨텍스트를 만든다.
// Module.cpp
void Module::show_graphic_display(int display_id, std::span<uint32_t> pix_buffer, unsigned width, lv_obj_t *canvas) {
	// ... ModuleWidget에서 display_id에 해당하는 widget 찾기 ...
			disp.args.vg = nvgCreatePixelBufferContext(canvas, pix_buffer, width, px_per_3U);

nvgCreatePixelBufferContext는 앞서 언급한 nanovg_pixbuf.cc에 있는 함수이고, 여기에서 ThorVG를 사용하는 renderFill, renderStroke등을 등록한다.

// nanovg_pixbuf.cc
nvgCreatePixelBufferContext(void *canvas, std::span<uint32_t> buffer, uint32_t buffer_width, uint32_t px_per_3U) {
	// ...
	params.renderFill = renderFill;
	params.renderStroke = renderStroke;
	// ...
	auto draw_ctx = new DrawContext{(lv_obj_t *)canvas, buffer, buffer_width};
	// DrawContext 안에서 tvg::SwCanvas 생성 (drawctx.hh)
	ctx = nvgCreateInternal(&params, nullptr);
  1. 매 프레임마다 Rack 위젯 draw를 호출하면 표면적으로 호출된 NanoVG API에서 백엔드로 ThorVG가 사용된다.
// Module.cpp
bool Module::draw_graphic_display(int display_id) {
		// ...
		nvgBeginFrame(disp.args.vg, disp.widget->box.getWidth(), disp.widget->box.getHeight(), 1);
		disp.widget->step();
		disp.widget->draw(disp.args);       // ← 플러그인/위젯이 nvg* 호출
		disp.widget->drawLayer(disp.args, 1);
		nvgEndFrame(disp.args.vg);
  1. 실제로 ThorVG가 nanovg_pixbuf.cc에서 호출된 renderFill함수 내부에서 그림을 그린다.
auto poly = tvg::Shape::gen();
poly->moveTo(...);
// ...
context->tvg_canvas->push(scene);
context->tvg_canvas->draw();
context->tvg_canvas->sync();

댓글

Discussion 원문