| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- 캐스팅연산자
- DAIVerse
- 유니티
- 동적 힙
- MacFilter
- C언어
- PHOTON
- OculusInteractionSamplesRayCanvas
- PointableCanvasModule
- 게임
- 클린 아키텍처
- 바이브코딩
- 메모리
- js
- VR
- 스펙주도형개발
- 게임은 문화
- Unity
- handtracking
- 온라인
- VR플랫폼
- linux
- TCP
- 파일패킹
- 캐시 메모리 사상
- 2D
- 게임제작
- 5G에그
- 게임개발
- C++
- Today
- Total
kunyoungparkk
ThorVG.web 프로젝트 분석 본문
2025 오픈소스 컨트리뷰션 아카데미 참여형 ThorVG 프로젝트에 멘티로 참여하고있습니다.
ThorVG의 wasm 빌드를 이용한 lottie-player 라이브러리를 생성하는 ThorVG.web 프로젝트를 최대한 간단하게 분석하는 글입니다.
근래에 wasm에 관해 관심이 많았는데,
emscripten 사용법을 공부했어도 어떻게 깔끔하고 사용하기 쉽게 사용자에게 전달할지가 고민이었습니다.
이 프로젝트에서는 Lit으로 wasm을 한번 더 Wrapping해서 보다 깔끔하고 직관적인 인터페이스를 제공합니다.
https://github.com/thorvg/thorvg.web
thorvg.web 레포지토리는 thorvg의 일부 기능을 webassembly로 포팅하여, 웹 환경에서 thorvg를 이용할 수 있게 해줍니다.
전반적으로 다음과 같이 구성됩니다.
- thorvg 메인 레포지토리를 git submodule을 이용해 관리합니다.
- 빌드 타임에 emscripten을 활용해 컴파일된 wasm/glue js/d.ts 파일들을 dist폴더로 복사 & thorvg/meson.build 이용해 버전 정보 갖고오고 번들링 수행
- thorvg repo의 wasm/glue js/d.ts + src의 lottie-player.ts 소스를 함께 빌드
- rollup 번들러를 사용하여 umd, cjs, esm 모듈 출력
몇가지 제가 오픈소스 기여를 위해 간단히 파악한 부분을 추가로 정리해보고자 합니다.
emscripten module와 어떻게 연동하고 있을까?
우선 src/lottie-player.ts를 살펴보겠습니다.
@customElement('lottie-player')
export class LottiePlayer extends LitElement
위와 같이, Lit이라는 라이브러리를 통해 컴포넌트 형태로 래핑돼있습니다.
public render(): TemplateResult {
return html`
<canvas class="thorvg" style="width: 100%; height: 100%;" />
`;
}
Lit을 사용하면, Lit 업데이트 시점에 해당 render 함수가 자동 호출됩니다.
먼저 이 방식으로 바인딩될 canvas를 생성하고
canvas가 커밋된 이후, firstUpdated 함수가 호출됩니다.
protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
this._canvas = this.querySelector('.thorvg') as HTMLCanvasElement;
this._canvas.id = `thorvg-${uuidv4().replaceAll('-', '').substring(0, 6)}`;
this._canvas.width = this._canvas.offsetWidth;
this._canvas.height = this._canvas.offsetHeight;
this._observer = new IntersectionObserver(this._observerCallback);
this._observer.observe(this);
if (!this._TVG) {
this._timer = setInterval(this._init.bind(this), 100);
return;
}
if (this.src) {
this.load(this.src, this.fileType);
}
}
멤버변수로 canvas를 캐싱해두고, src가 이미 외부에서 주입됐다면, load함수 호출합니다.
public async load(src: string | object, fileType: FileType = FileType.JSON): Promise<void> {
try {
await this._init();
const bytes = await _parseSrc(src, fileType);
this.dispatchEvent(new CustomEvent(PlayerEvent.Ready));
this.fileType = fileType;
await this._loadBytes(bytes);
} catch (err) {
this.currentState = PlayerState.Error;
this.dispatchEvent(new CustomEvent(PlayerEvent.Error));
}
}
load 함수에서는 this._init 함수를 호출합니다.
private async _init(): Promise<void> {
// Ensure module is loaded only once
if (_moduleRequested) {
while (!_module) {
await _wait(100);
}
}
if (!_module) {
_moduleRequested = true;
_module = await Module({
locateFile: (path: string, prefix: string) => {
if (path.endsWith('.wasm')) {
return this.wasmUrl || _wasmUrl;
}
return prefix + path;
}
});
}
if (!this._timer) {
//NOTE: ThorVG Module has loaded, but called this function again
return;
}
clearInterval(this._timer);
this._timer = undefined;
const engine = this.renderConfig?.renderer || Renderer.SW;
await _initModule(engine);
if (_initStatus === InitStatus.FAILED) {
this.currentState = PlayerState.Error;
this.dispatchEvent(new CustomEvent(PlayerEvent.Error));
return;
}
this._TVG = new _module.TvgLottieAnimation(engine, `#${this._canvas!.id}`);
if (this.src) {
this.load(this.src, this.fileType);
}
}
함수의 초입단계에서 wasm 모듈이 로드되기를 기다립니다.
이후 엔진단의 초기화 과정을 거친 후,
emscripten embind로 바인딩된 TvgLottieAnimation 객체를 생성합니다.
tvgWasmLottieAnimation.cpp
EMSCRIPTEN_BINDINGS(thorvg_bindings)
{
emscripten::function("init", &init);
emscripten::function("term", &term);
class_<TvgLottieAnimation>("TvgLottieAnimation")
.constructor<string, string>()
.function("error", &TvgLottieAnimation ::error, allow_raw_pointers())
.function("size", &TvgLottieAnimation ::size)
.function("duration", &TvgLottieAnimation ::duration)
.function("totalFrame", &TvgLottieAnimation ::totalFrame)
.function("curFrame", &TvgLottieAnimation ::curFrame)
.function("render", &TvgLottieAnimation::render)
.function("load", &TvgLottieAnimation ::load)
.function("update", &TvgLottieAnimation ::update)
.function("frame", &TvgLottieAnimation ::frame)
.function("viewport", &TvgLottieAnimation ::viewport)
.function("resize", &TvgLottieAnimation ::resize)
.function("save", &TvgLottieAnimation ::save);
}
어떻게 wasm을 번들링하는가?
wasm은 es6 정적 import를 지원하고 있지 않기 때문에 번들링이 까다로운 편입니다.
현재 thorvg.web에서는 unpkg를 통해 wasm에 대한 CDN url을 제공하는 방식을 사용하고 있습니다.
또다른 방법으로는 SINGLE_FILE을 이용해서 glue js 안에 base64 형태로 합치는 방법이 있습니다.
라이브러리 크기 자체는 wasm을 별도로 놓는 것이 사이즈가 작은 이점이 있겠지만, 서버로 GET요청을 한번 더 보내야하기 때문에 트레이드 오프 관계가 있습니다.
인스턴스 생성/삭제 등 관리를 어떻게 하고 있는가?
1. TvgLottieAnimation의 생성
TvgLottieAnimation은 여러개 존재할 수 있습니다.
이 때 각각의 TvgLottieAnimation는 다른 Canvas이므로,
(webgl 백엔드의 경우) 아래와 같이 현재 Canvas의 context로 스위칭한 후 렌더링합니다.
- 생성 시점 : 웹 부분
private async _init(): Promise<void> {
// 1. 모듈 중복 로드 방지하면서, 안정적으로 로드
if (_moduleRequested) {
while (!wasmModule) {
await _wait(100);
}
}
if (!wasmModule) {
_moduleRequested = true;
wasmModule = await Module({
locateFile: (path: string, prefix: string) => {
if (path.endsWith('.wasm')) {
return this.wasmUrl || _wasmUrl;
}
return prefix + path;
}
});
}
if (!this._timer) {
//NOTE: ThorVG Module has loaded, but called this function again
return;
}
clearInterval(this._timer);
this._timer = undefined;
const engine = this.config?.renderer || (DEFAULT_RENDERER as Renderer);
await _initModule(engine);
if (_initStatus === InitStatus.FAILED) {
this.currentState = PlayerState.Error;
this.dispatchEvent(new CustomEvent(PlayerEvent.Error));
return;
}
//canvas id를 제공하며 생성
this.TVG = new wasmModule.TvgLottieAnimation(engine, `#${this.canvas!.id}`);
if (this.src) {
this.load(this.src, this.fileType);
}
}
- 생성 시점: C++ (웹어셈블리 모듈 내부)
전달받은 canvas id를 활용해서, emscripten의 webgl context를 초기화주고, 여러 옵션을 설정해줍니다.
Canvas* init(string& selector) override
{
EmscriptenWebGLContextAttributes attrs{};
attrs.alpha = true;
attrs.depth = false;
attrs.stencil = false;
attrs.premultipliedAlpha = true;
attrs.failIfMajorPerformanceCaveat = false;
attrs.majorVersion = 2;
attrs.minorVersion = 0;
attrs.enableExtensionsByDefault = true;
context = emscripten_webgl_create_context(selector.c_str(), &attrs);
if (context == 0) return nullptr;
emscripten_webgl_make_context_current(context);
if (Initializer::init() != Result::Success) return nullptr;
loadFont();
return GlCanvas::gen();
}
- 렌더링 시, (webgl의 경우) 내부적으로 rendering 이전에 현재 gl context로 스위칭합니다.
bool GlRenderer::preRender()
{
if (mRootTarget.invalid()) return false;
currentContext();
if (mPrograms.empty()) initShaders();
mRenderPassStack.push(new GlRenderPass(&mRootTarget));
return true;
}
- 종료 시점
ThorVG.web에서는 destroy 시에 생성한 TVG 객체를 명시적으로 delete하는 등 멤버를 정리하고,
this를 remove합니다.
public destroy(): void {
if (!this._TVG) {
return;
}
this._TVG.delete();
this._TVG = null;
this.currentState = PlayerState.Destroyed;
if (this._observer) {
this._observer.disconnect();
this._observer = undefined;
}
this.dispatchEvent(new CustomEvent(PlayerEvent.Destroyed));
this.remove();
}
웹어셈블리 모듈 내부적으로는 아래와 같이 처리합니다.
~TvgGLEngine()
{
if (context) {
Initializer::term();
emscripten_webgl_destroy_context(context);
context = 0;
}
retrieveFont();
}
'프로젝트 > OpenSource' 카테고리의 다른 글
| [2025 OSSCA] 참여형 후기 (0) | 2025.12.20 |
|---|