서론
LVGL(공식 리포)은 임베디드 시스템에서 사용되는 오픈소스 그래픽스 라이브러리로, 렌더링을 위한 여러 방식을 지원합니다: LVGL Docs - Draw Units
ThorVG는 그 중에서도 소프트웨어 렌더링을 사용할 시 벡터 그래픽을 렌더링하기 위해 사용됩니다. 이 글에서는 LVGL이 ThorVG를 어떤 방식으로 가져와서 통합했고, 실제로 어디에 사용하는지를 코드 기준으로 살펴보았습니다.
LVGL이 ThorVG를 선택한 이유
LVGL의 이슈를 살펴보면 2021년에 최초로 ThorVG에 대한 언급이 된 이후로, 여러 이슈 & PR에서 ThorVG의 도입에 대해 논의되었던 것으로 보입니다.
LVGL 포럼의 한 글의 댓글을 보면 2023년 즈음에 LVGL에 벡터 그래픽스 기능 추가가 마일스톤에 있었고, 어떤 라이브러리를 사용해야할지 고민하고 있었던 것 같습니다. hermet님께서 ThorVG의 경량성과 Lottie 지원 예정에 대해 말씀하시는 것도 보입니다. 그러다가 LVGL 측 개발자가 직접 ThorVG에 이슈를 작성해 LVGL에 ThorVG를 추가하고 싶다고 요청했습니다. 이는 임베디드 기기에서 돌아가기 위한 작은 바이너리 크기, Lottie 지원 등이 이유인 것으로 생각됩니다.
통합 방식
NOTE
LVGL은 타겟에 따라 여러 빌드 경로를 지원합니다. 이 글에서는 그 중 CMake 기반 빌드 흐름을 조사했습니다.
옵션 설정
LVGL Docs - Lottie를 보면, LVGL에서 Lottie를 사용하기 위해선 lv_conf.h에서 LV_USE_THORVG_INTERNAL(포함된 ThorVG 사용) 또는 LV_USE_THORVG_EXTERNAL(외부 링크 사용)을 활성화해야 하며, 또한 벡터 그래픽스이기 때문에 LV_USE_VECTOR_GRAPHIC 또한 활성화해야한다고 되어 있습니다.
다만 실제 코드를 보면
LV_USE_THORVG_EXTERNAL은 v9.6부터 deprecated 되었으며, 현재는LV_USE_THORVG를 켜고LV_USE_THORVG_INTERNAL을 끄는 방식으로 대체되었다고 합니다. (lv_conf_internal.h에서 발췌)
물론 LVGL에 있어 ThorVG는 선택 기능이기 때문에 활성화하지 않고 다른 벡터 그래픽스 백엔드를 사용할 수도 있습니다. 다만 백엔드가 하나도 없다면 에러가 발생합니다.
// lv_draw_vector.c
#if !((LV_USE_DRAW_SW && LV_USE_THORVG) || LV_USE_DRAW_VG_LITE || (LV_USE_NEMA_GFX && LV_USE_NEMA_VG) || LV_USE_DRAW_NANOVG)
#error "LV_USE_VECTOR_GRAPHIC requires (LV_USE_DRAW_SW and LV_USE_THORVG) or LV_USE_DRAW_VG_LITE or (LV_USE_NEMA_GFX and LV_USE_NEMA_VG) or LV_USE_DRAW_NANOVG"
#endif
LV_USE_THORVG_INTERNAL옵션에 대해 더 알아보자면, lvgl/src/libs/thorvg/ 안에 ThorVG 소스 트리 전체가 그대로 복사되어 들어와 있습니다. INTERNAL은 이 ThorVG를 쓰는 것이고, EXTERNAL은 별도로 사용자가 시스템에서 설치한 ThorVG를 제공해야 합니다.- 또한 포함된 ThorVG 파일들을 보면 모두
LV_USE_THORVG_INTERNAL가드로 싸여 있는데요, 이는 add_lvgl_if.sh 스크립트를 사용한 것으로 보입니다.
다시 돌아와서 lv_conf.h에 대해 살펴보겠습니다. 리포에는 lv_conf.h가 보이지 않는데요, LVGL Docs - Overview를 보면 루트에 있는 lv_conf_template.h를 lv_conf.h라는 이름으로 복사한 후 필요한 만큼 옵션을 설정하면 되는 것임을 알 수 있습니다. lv_conf_template.h의 최상단에는 아래와 같이 설명되어있습니다.
/*
* Copy this file as `lv_conf.h`
* 1. simply next to `lvgl` folder
* 2. or to any other place and
* - define `LV_CONF_INCLUDE_SIMPLE`;
* - add the path as an include path.
*/
lv_conf_template.h에서 ThorVG와 관련된 옵션을 찾아보면:
먼저 ThorVG가 활성화 되었을 때 스레드 스택 사이즈를 32KB 이상으로 설정하는 것을 권장하고 있습니다.
/** If FreeType or ThorVG is enabled, it is recommended to set it to 32KB or more. */
#define LV_DRAW_THREAD_STACK_SIZE 8192
다음으로 벡터 그래픽스 활성화, ThorVG 활성화 부분입니다.
/** Enable drawing support vector graphic APIs.
*
* Enable: LV_USE_MATRIX
*/
#define LV_USE_VECTOR_GRAPHIC 0
// ...
/** ThorVG library for vector graphics support */
#define LV_USE_THORVG 0
#if LV_USE_THORVG
/** Internal ThorVG library bundled with LVGL */
#define LV_USE_THORVG_INTERNAL 1
#endif /*LV_USE_THORVG*/
아래 부분은 VG-Lite 라는 Draw Unit을 ThorVG를 사용하여 시뮬레이션하는 기능과 관련된 옵션들입니다.
/** Use thorvg to simulate VG-Lite hardware behavior, it's useful
* for debugging and testing on PC simulator. Enable LV_USE_THORVG,
* Either internal ThorVG or external ThorVG library is required.
*
* Enable: LV_USE_THORVG
*/
#define LV_USE_VG_LITE_THORVG 0
#if LV_USE_VG_LITE_THORVG
/** Enable LVGL blend mode support */
#define LV_VG_LITE_THORVG_LVGL_BLEND_SUPPORT 0
/** Enable YUV color format support */
#define LV_VG_LITE_THORVG_YUV_SUPPORT 0
/** Enable linear gradient extension support */
#define LV_VG_LITE_THORVG_LINEAR_GRADIENT_EXT_SUPPORT 0
/** Enable 16 pixels alignment */
#define LV_VG_LITE_THORVG_16PIXELS_ALIGN 1
/** Buffer address alignment */
#define LV_VG_LITE_THORVG_BUF_ADDR_ALIGN 64
/** Enable multi-thread render */
#define LV_VG_LITE_THORVG_THREAD_RENDER 0
#endif /*LV_USE_VG_LITE_THORVG*/
마지막으로는 Lottie 관련 옵션입니다.
#if LV_DRAW_HAS_VECTOR_SUPPORT
#if LV_USE_THORVG
/** Enable Lottie animations
*
* Enable: LV_USE_VECTOR_GRAPHIC
*/
#define LV_USE_LOTTIE 0
#endif /*LV_USE_THORVG*/
#endif /*LV_DRAW_HAS_VECTOR_SUPPORT*/
CMake
CMakeLists.txt가 플랫폼에 따라 적절한 .cmake 파일을 include합니다.
if(ESP_PLATFORM)
include(${CMAKE_CURRENT_LIST_DIR}/env_support/cmake/esp.cmake)
elseif(MICROPY_DIR)
include(${CMAKE_CURRENT_LIST_DIR}/env_support/cmake/micropython.cmake)
else()
include(${CMAKE_CURRENT_LIST_DIR}/env_support/cmake/main.cmake)
endif()
ESP 또는 MicroPython이 아니라고 가정하고 env_support/cmake/main.cmake를 보면 아래와 같은 ThorVG 관련 부분이 보입니다.
option(CONFIG_LV_USE_THORVG_INTERNAL "Use the internal version of ThorVG" ON)
// ...
if(CONFIG_LV_USE_THORVG_INTERNAL)
message(STATUS "Enabling the building of ThorVG internal")
add_library(lvgl_thorvg ${THORVG_SOURCES})
add_library(lvgl::thorvg ALIAS lvgl_thorvg)
target_include_directories(lvgl_thorvg PRIVATE ${LVGL_ROOT_DIR}/src/libs/thorvg)
set_target_properties(lvgl_thorvg PROPERTIES COMPILE_DEFINITIONS "${COMP_DEF}")
# This tells cmake to link lvgl with lvgl_thorvg
# The linker will resolve all dependencies when dynamic linking
target_link_libraries(lvgl PRIVATE lvgl_thorvg)
# export lvgl_thorvg as a private library to pkg-config
get_property(current_libs GLOBAL PROPERTY LVGL_PKG_LIBS_PRIVATE)
list(APPEND current_libs "-llvgl_thorvg")
set_property(GLOBAL PROPERTY LVGL_PKG_LIBS_PRIVATE "${current_libs}")
# During static linking, we need to create a cyclic dependency as thorvg also needs lvgl
if (NOT BUILD_SHARED_LIBS)
target_link_libraries(lvgl_thorvg PRIVATE lvgl)
endif()
endif()
INTERNAL이 켜져 있을 때 lvgl_thorvg라는 타겟을 생성하고 링크하는 것을 볼 수 있습니다. EXTERNAL의 경우는 사용자가 직접 링크해줘야 하는 것으로 보입니다.
앞에 CONFIG_ prefix가 붙은 변수들이 보이는데요, 방금 전 lv_conf.h에서 설정했던 옵션들임을 알 수 있습니다. 다만 어떻게 lv_conf.h에서 매크로로 설정한 옵션들이 CMake에 반영되는지가 궁금해서 알아본 결과, LVGL Docs - CMake를 보면 해당 변수들은 -D로 수동으로 설정할 수도 있지만, LV_BUILD_SET_CONFIG_OPTS를 활성화하여 빌드 시스템이 lv_conf_internal.h을 파싱하여 자동으로 변수들을 생성하게 할 수도 있다고 합니다.
config.h
lv_conf.h에 설정된 변수들이 ThorVG 내부 코드들에는 어떻게 영향을 주는지는 src/libs/thorvg/config.h를 보면 알 수 있습니다.
/*
* Autogenerated by the Meson build system.
* Do not edit, your changes will be lost.
*/
#ifndef TVG_CONFIG_H
#define TVG_CONFIG_H
#include "../../lvgl_public.h"
#define THORVG_SW_RASTER_SUPPORT 1
#define THORVG_SVG_LOADER_SUPPORT LV_USE_LOTTIE
#define THORVG_LOTTIE_LOADER_SUPPORT LV_USE_LOTTIE
#define THORVG_VERSION_STRING "0.15.3"
#if LV_DRAW_SW_DRAW_UNIT_CNT > 1
#define THORVG_THREAD_SUPPORT
#endif
#endif /*TVG_CONFIG_H*/
방금 설정한 LV_USE_LOTTIE 등을 그대로 ThorVG의 매크로의 값으로 사용하고, 결과적으로 ThorVG 내부는 lv_conf.h에서 설정한 대로 동작하게 됩니다. 사용자가 ThorVG의 빌드 옵션을 알 필요 없이 lv_conf.h만 설정하면 ThorVG의 내부 동작까지 제어하도록 설계되어 있습니다. 대신 버전은 0.15.3에 고정되어 있는 것으로 보입니다.
ThorVG 사용례
다음으로는 ThorVG가 실제로 어떻게 사용되는지 알아보았습니다. 먼저 ThorVG가 사용되는 파일 목록을 정리해보면 아래와 같습니다.
| 파일 | 하는 일 |
|---|---|
src/draw/sw/lv_draw_sw.c | 엔진 라이프사이클, draw task 디스패치 |
src/draw/sw/lv_draw_sw_vector.c | 벡터 그래픽 SW 백엔드 |
src/draw/sw/lv_draw_sw_letter.c | FreeType 벡터 글리프 렌더링 |
src/widgets/lottie/lv_lottie.c | Lottie 위젯 |
src/debugging/vg_lite_tvg/vg_lite_tvg.cpp | VG-Lite GPU 시뮬레이터 (디버깅용) |
lv_draw_sw.c
#if LV_USE_THORVG
#if LV_USE_THORVG_INTERNAL
#include "../../libs/thorvg/thorvg_capi.h"
#else
#include <thorvg_capi.h>
#endif
#endif
line 17~23: C++ 헤더인 thorvg.h가 아니라 C API 바인딩인 thorvg_capi.h를 씁니다. 또한 리포에 포함된 버전을 사용할 지, 또는 시스템에 설치된 버전을 사용할 지도 선택합니다.
#if LV_USE_VECTOR_GRAPHIC && LV_USE_THORVG
if(LV_DRAW_SW_DRAW_UNIT_CNT > 1) {
tvg_engine_init(TVG_ENGINE_SW, LV_DRAW_SW_DRAW_UNIT_CNT);
}
else {
tvg_engine_init(TVG_ENGINE_SW, 0);
}
#endif
line 101~108: SW 렌더러를 초기화하는 lv_draw_sw_init() 함수 내에서 tvg_engine_init()을 호출하여 엔진을 초기화하며, LV_DRAW_SW_DRAW_UNIT_CNT에 따라 스레드 개수를 설정합니다.
#if LV_USE_VECTOR_GRAPHIC && LV_USE_THORVG
tvg_engine_term(TVG_ENGINE_SW);
#endif
line 115~117: SW 렌더러를 해제하는 lv_draw_sw_deinit() 함수 내에서 ThorVG 엔진을 해제합니다.
#if LV_USE_VECTOR_GRAPHIC && LV_USE_THORVG
case LV_DRAW_TASK_TYPE_VECTOR:
lv_draw_sw_vector(t, t->draw_dsc);
break;
#endif
line 423~427: 작업의 타입에 따라 분기하는 execute_drawing 함수 내에서 벡터 그래픽 작업을 ThorVG에게 시키는 부분입니다. lv_draw_sw_vector() 함수의 내용은 lv_draw_sw_vector.c에 정의되어 있으며 바로 아래에서 다룹니다.
lv_draw_sw_vector.c
LVGL의 벡터 그래픽 API를 ThorVG의 C API 바인딩으로 넘겨주는 역할을 합니다. 자체적인 렌더링 로직은 없고, 진입점 하나와 콜백 하나, 그리고 여러 변환 헬퍼들로 구성되어 있습니다.
진입점인 lv_draw_sw_vector() 함수는 아래와 같은 흐름을 가집니다.
void lv_draw_sw_vector(lv_draw_task_t * t, lv_draw_vector_dsc_t * dsc)
{
// ...
// LVGL의 버퍼 확보
lv_layer_t * layer = dsc->base.layer;
lv_draw_buf_t * draw_buf = layer->draw_buf;
if(draw_buf == NULL)
return;
void * buf = draw_buf->data;
// ...
// 캔버스를 생성하고 위에서 얻은 버퍼를 렌더 타겟으로 설정
Tvg_Canvas * canvas = tvg_swcanvas_create();
tvg_swcanvas_set_target(canvas, buf, stride / 4, width, height, TVG_COLORSPACE_ARGB8888);
// ...
// LVGL이 쌓아둔 태스크 목록을 순회하며 도형을 캔버스에 등록(콜백을 통해 수행)
lv_ll_t * task_list = dsc->task_list;
lv_vector_for_each_destroy_tasks(task_list, _task_draw_cb, &state);
dsc->task_list = NULL;
// 캔버스에 등록된 도형들을 한 번에 그리기(LVGL 버퍼에 직접 쓰기)
if(tvg_canvas_draw(canvas) == TVG_RESULT_SUCCESS) {
tvg_canvas_sync(canvas);
}
// ...
// 캔버스 파괴
tvg_canvas_destroy(canvas);
}
콜백 함수 _task_draw_cb()은 LVGL과 렌더링 백엔드 간의 호환성을 위한 것입니다. LVGL은 벡터 그리기 명령을 즉시 실행하지 않고 task_list에 쌓아둡니다. 이 리스트를 순회하는 lv_vector_for_each_destroy_tasks()는 lv_draw_vector.c의 공용 함수라 현재 어떤 백엔드를 사용하고 있는지 모릅니다. 그래서 “task 하나를 그리는 일”만 콜백으로 넘깁니다. _task_draw_cb()는 task 하나를 받아 tvg_shape_new()로 도형을 만들고 속성을 설정한 뒤 tvg_canvas_push()로 캔버스에 등록합니다.
static void _task_draw_cb(void * ctx, const lv_vector_path_t * path, const lv_vector_path_ctx_t * dsc)
{
// ...
Tvg_Paint * obj = tvg_shape_new();
// ...
_set_paint_matrix(obj, &mtx);
_set_paint_shape(obj, path);
_set_paint_fill(obj, canvas, &dsc->fill_dsc, &matrix, state->opa);
_set_paint_stroke(obj, &dsc->stroke_dsc);
_set_paint_blend_mode(obj, dsc->blend_mode);
// ...
tvg_paint_set_opacity(obj, state->opa);
tvg_canvas_push(canvas, obj);
}
파일의 나머지 내용은 lv_area_to_tvg(), lv_color_to_tvg(), lv_matrix_to_tvg() 등의 헬퍼 함수로, LVGL의 구조체와 ThorVG 구조체 간 변환을 지원하는 함수입니다.
lv_draw_sw_letter.c
FreeType이 폰트의 벡터 정보를 제공할 때 글자를 그리는 동작을 수행합니다. 방금 다룬 lv_draw_sw_vector()를 내부적으로 호출하여 ThorVG를 사용합니다.
FreeType은 폰트 파일을 입력으로 받아 소프트웨어 래스터라이제이션, 벡터 추출 등을 수행하는 라이브러리입니다.
static void draw_letter_outline(lv_draw_task_t * t, lv_draw_glyph_dsc_t * glyph_dsc)
{
// ...
/*Can't call lv_draw_vector() as it would create a new draw task while
*the main thread also can create draw tasks. So create a dummy draw task
*manually to draw the outline*/
if(vector_dsc->task_list) {
lv_draw_task_t dummy_t;
lv_memzero(&dummy_t, sizeof(lv_draw_task_t));
dummy_t.area = vector_dsc->base.layer->_clip_area;
dummy_t._real_area = vector_dsc->base.layer->_clip_area;
dummy_t.clip_area = vector_dsc->base.layer->_clip_area;
dummy_t.target_layer = vector_dsc->base.layer;
dummy_t.type = LV_DRAW_TASK_TYPE_VECTOR;
dummy_t.opa = LV_OPA_COVER;
dummy_t.draw_dsc = vector_dsc;
lv_draw_sw_vector(&dummy_t, dummy_t.draw_dsc);
}
// ...
}
즉 ThorVG에 작업을 위임하는 함수는 lv_draw_sw_vector에 존재하고, lv_draw_sw.c와 lv_draw_sw_letter.c 두 곳에서 호출하는 구조입니다.
lv_lottie.c
Lottie 애니메이션을 재생하는 위젯입니다. 앞의 두 파일과 달리 ThorVG의 렌더링 뿐만 아니라 Lottie 관련 기능까지 사용합니다.
아래 생성자는 tvg_animation_new()로 애니메이션 객체를, tvg_swcanvas_create()로 캔버스를 만듭니다. 동시에 lv_anim_start()로 LVGL의 애니메이션도 시작합니다.
static void lv_lottie_constructor(const lv_obj_class_t * class_p, lv_obj_t * obj)
{
LV_UNUSED(class_p);
LV_TRACE_OBJ_CREATE("begin");
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_lottie_t * lottie = (lv_lottie_t *)obj;
lottie->tvg_anim = tvg_animation_new();
lottie->tvg_paint = tvg_animation_get_picture(lottie->tvg_anim);
lottie->tvg_canvas = tvg_swcanvas_create();
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_exec_cb(&a, anim_exec_cb);
lv_anim_set_var(&a, obj);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lottie->anim = lv_anim_start(&a);
LV_TRACE_OBJ_CREATE("finished");
}
사용할 애니메이션 파일 또는 데이터의 지정은 lv_lottie_set_src_data()와 lv_lottie_set_src_file()로 수행합니다.
lottie_update() 함수는 프레임 번호 v를 받아 tvg_animation_set_frame()에 전달해 해당 프레임을 그립니다.
static void lottie_update(lv_lottie_t * lottie, int32_t v)
{
lv_obj_t * obj = (lv_obj_t *) lottie;
lv_draw_buf_t * draw_buf = lv_canvas_get_draw_buf(obj);
if(draw_buf) {
lv_draw_buf_clear(draw_buf, NULL);
/*Drop old cached image*/
lv_image_cache_drop(lv_image_get_src(obj));
}
tvg_animation_set_frame(lottie->tvg_anim, v);
tvg_canvas_update(lottie->tvg_canvas);
tvg_canvas_draw(lottie->tvg_canvas);
tvg_canvas_sync(lottie->tvg_canvas);
lv_obj_invalidate(obj);
}
vg_lite_tvg.cpp
LVGL은 VG-Lite라는 하드웨어 가속 백엔드를 사용하기도 합니다. vg_lite_tvg.cpp는 ThorVG를 기반으로 VG-Lite를 시뮬레이션하는 구현체며, 디버깅 용도라고 합니다: LVGL Docs - VG-Lite Simulator
LV_USE_VG_LITE_THORVG가 활성화되면 lv_vg_lite_utils.h를 비롯한 여러 파일들에서 아래와 같이 실제 드라이버 헤더 대신 src/debugging/vg_lite_tvg/vg_lite.h를 include합니다.
#if LV_USE_VG_LITE_THORVG
#include "../../debugging/vg_lite_tvg/vg_lite.h"
#else
#if LV_USE_VG_LITE_DRIVER
#include "../../libs/vg_lite_driver/inc/vg_lite.h"
#else
#include <vg_lite.h>
#endif
#endif
결론
LVGL은 기본적으로는 ThorVG를 외부 종속성으로 두지 않고 소스를 그대로 리포에 포함시킨 뒤, 빌드 옵션과 매크로를 사용자가 입맛에 맞게 설정하도록 해 뒀습니다. 여러 환경/케이스를 지원해야 하는 경우 어떻게 사용자에게 선택권을 줄 지에 대한 한 가지 방향성을 엿볼 수 있었습니다.
글의 앞부분에서도 보았듯이 LVGL은 ThorVG 말고도 다른 벡터 그래픽스 백엔드를 사용할 수도 있습니다만, LVGL 문서에서 언급되는 다른 대안인 VG-Lite, NemaVG, NanoVG는 모두 조사해본 결과 기본적으로 GPU 가속을 필수로 요구합니다. 반면 ThorVG는 완전한 SW 백엔드를 제공하기 때문에 선택 가능한 벡터 그래픽스 백엔드 사이에서 상당한 경쟁력을 가지는 것으로 보입니다. 여기에 특유의 경량성까지 더해져 임베디드를 타겟으로 하는 LVGL에 딱 맞는 라이브러리라고 생각이 듭니다.
흥미롭게도 GPU를 요구하는 VG-Lite 백엔드조차 GPU 없이 테스트할 때는 ThorVG로 구현한 시뮬레이터를 사용하는데요, 다소 특수한 사용례인 것 같습니다.
또한 lv_draw_sw_vector.c에서도 보여지듯이, 외부 라이브러리를 프로젝트에 통합할 때 기존 시스템과 잘 어우러지게하는 계층이 필요하다는 것도 실감했습니다.
댓글
Discussion 원문