Metamodule이란?
Eurorack이라는 모듈러 신디사이저에 넣을 수 있는 하드웨어 모듈의 한 종류로, 해당 모듈에서 VCV Rack이라는 PC프로그램에서 기존에 사용했던 가상 모듈 패치 여러개를 한번에 사용할 수 있게 해주는 역할을 한다. (이때 모듈이란, Oscillator나 Filter를 노브로 조정해서 사용자가 원하는 사운드를 만드는 역할을 해주는 하나의 구역이라고 보면 된다.)
해당 모듈을 사용할 때 모듈의 스크린에서 세부적인 설정들을 조절할 수 있는데, 이 설정들의 GUI를 표시하는데에 ThorVG가 사용된다.
또한, 이 하드웨어를 PC에서 시뮬레이션 하는데에도 GUI로 ThorVG가 사용되고 있다.
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가 그려지는 흐름을 정리해보았다.
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();
}
- 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);
}
- 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(¶ms, nullptr);
- 매 프레임마다 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);
- 실제로 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 원문