kunyoungparkk

ThorVG.web 프로젝트 분석 본문

프로젝트/OpenSource

ThorVG.web 프로젝트 분석

박건영 2025. 8. 12. 17:49

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