← 블로그 목록

webgl 캔버스의 png export 불가능

@chae-dahee
  • #Study
목차

webgl png export

thorvg.web의 이슈를 #139 “lottie: can’t export to png with webgl canvas” 분석한다.

브라우저 렌더링 동작, WASM 바인딩, /thorvg 상위 라이브러리의 save2png 기능에 걸친 의존 구조 원인 구조, 경로 대비를 먼저 정리한다.

요약

WebGL 백엔드에서 save2png가 빈 이미지를 만든다. 원인은 브라우저가 프레임 렌더 후 drawing buffer를 비우기 때문이고, 현재 save2png가 그 비워진 캔버스를 직접 읽는 방식(canvas.toBlob())이라서다.

해결 방향은 캔버스를 읽지 않고 save2gif처럼 ThorVG saver로 저장하는 것으로 보인다. 현재 saver(PNG)가 상위 라이브러리 core에 없어서, thorvg#3737의 core 선행작업에 이어서 진행해야 할 것 이다. 기술적으로는 web 전용 encoder를 도입하는 우회도 가능하나, 근본적인 해결은 아니다.

왜 WebGL 에서만 실패하지?

렌더링엔진

  • sw (Software) : CPU 렌더링, GPU 를 사용하지 않고 계산해서 나온 픽셀을 일반 메모리 버퍼(CPU메모리)에 작성한다
  • gl (WebGL) : GPU를 WebGL 로 사용한다. 기본값
  • wg (WebGPU) : GPU 를 WebGPU 로 사용한다.

브라우저는 WebGL 캔버스에 렌더링한 뒤 기본적으로 drawing buffer를 flush(clear)한다. 이는 성능을 위한 브라우저의 기본 최적화다. 그 결과 렌더가 끝난 시점에 캔버스 픽셀을 읽으려 하면 이미 비워진 버퍼를 읽게 된다.

Software(CPU) 렌더러는 CPU 메모리 버퍼에 그리므로 문제가 없다. 이 이슈는 WebGL 백엔드에서만 재현된다. viewer(thorvg.view #124), web(thorvg.web #139)에서 동일하게 확인되었다.

참고 자료: WebGL2 Fundamentals — Taking a Screenshot of the Canvas

코드 관점

save2pngsave2gif서로 다른 데이터 소스를 쓴다

  • save2png (packages/lottie-player/src/lottie-player.ts:58-70)
    • this.canvas!.toBlob(...) : HTML 캔버스 픽셀을 직접 읽는다. WebGL 버퍼가 비워지면 빈 이미지가 나온다.
  • save2gif (packages/lottie-player/src/lottie-player.ts:76-101)
    • 캔버스를 전혀 읽지 않는다. SW 렌더러를 새로 만들어 저장하고, 그 결과 파일을 읽는다. (목차 3. save2gif 패턴)

즉, save2png를 toBlob 방식(캔버스 읽기)에서 save2gif 방식(파일 읽기)으로 전환하는 것이 필요하다.

#124 이슈: save2gif 코멘트 방향

가장 간단한 해법은 Emscripten의 GL_TESTING 옵션(내부적으로 preserveDrawingBuffer를 켬)을 활성화하는 것이다. WebGL 컨텍스트가 렌더 후에도 버퍼를 보존하므로 캔버스를 읽을 수 있게 된다.

그러나

save2png 기능 하나 때문에 전역 렌더링 성능에 영향을 주는 옵션을 켜는 것은 옳지 않다. 프레임 후 버퍼 flush는 브라우저의 기본 최적화이며, 이를 우회해서는 안 된다.

벤치마크의 성능차이와 별개로 브라우저의 기본 동작을 우회하는 의도는 과연 적합한가?

save2gif 패턴: 캔버스를 읽지 않음

원본 데이터를 SW 렌더러로 재로드해 ThorVG saver 로 인코딩하고, 그 결과 파일을 되읽는다

C++ 부분 : wasm/lottie-player/tvgWasmLottieAnimation.cpp

save(data, mimetype)가 mimetype으로 분기하고(:434), "gif"save2Gif를 호출한다(:441).

save2Gif

auto saver = unique_ptr<Saver>(Saver::gen());
auto animation = unique_ptr<Animation>(Animation::gen());
animation->picture()->load(data.c_str(), data.size(), "lot", nullptr, false);
// ... 스케일/배경 설정 ...
saver->save(animation.release(), "output.gif", 100, 30);   // MEMFS에 파일로 저장
saver->sync();

화면 캔버스의 상태와 독립적으로, 원본 데이터를 새 Animation으로 로드해 Saver로 저장한다. 저장 대상은 Emscripten 가상 파일시스템(MEMFS)의 output.gif다.

JS 부분 회수 경로 : packages/lottie-player/src/lottie-player.ts:76-101`

const saver = new wasmModule.TvgLottieAnimation(Renderer.SW, `#${this.canvas!.id}`); // SW 인스턴스 별도 생성
const bytes = await parseSrc(src, FileType.JSON);
const isExported = saver.save(bytes, 'gif');            // MEMFS에 output.gif 기록
// ...
const data = wasmModule.FS_readFile('output.gif');     // MEMFS에서 파일 되읽기
const blob = new Blob([data], {type: 'application/octet-stream'});
_downloadFile('output.gif', blob);                     // 브라우저 다운로드 트리거
saver.delete();

회수 방식: C++ saver가 MEMFS에 파일로 저장 → JS가 FS_readFile로 그 파일을 읽음 → Blob 다운로드 캔버스 픽셀을 읽는 단계가 없으므로 WebGL 버퍼 flush 문제의 영향을 받지 않는다.

PNG saver 방식 : 정적 이미지

GIF와 PNG는 저장 대상 타입이 다르다. ThorVG의 saver 시그니처를 보면 두 오버로드로 갈린다(inc/thorvg.h).

Result save(Paint* paint, const char* filename, uint32_t quality = 100) noexcept;                     // :2757 정적 이미지
Result save(Animation* animation, const char* filename, uint32_t quality = 100, uint32_t fps = 0);    // :2780 시간축(GIF)
  • GIF save(Animation*, ...) : 애니메이션 전체를 fps와 함께 저장한다.
  • PNG save(Paint*, ...) : 단일 정적 이미지 Paint
    • frame이나 fps 인자를 받지 않는다.

core 상위 작업 #3737

save2gif 패턴을 PNG에 적용하려면 ThorVG core에 PNG saver가 있어야 하는데, 현재 core에는 없다.

  • thorvg/src/savers/ : gif/만 있고 png/는 없다.
  • thorvg/meson_options.txt savers 선택지 : ['', 'gif', 'all']뿐, PNG saver 모듈 없다.
  • PNG 인코딩 자원 : 존재한다. saver로 올라가지 않고 tool에 있다.
    • thorvg/tools/svg2png/ - lodepng.cpp
    • svg2png.cppSwCanvas::gen()canvas->target(buffer, ...)lodepng::encode(...) 패턴

즉, core 에서 구현이 필요한 기능이다.

thorvg/thorvg#3737 “support png saver”

  • 상태 OPEN, 담당자 O
  • ThorVG 장면을 파일로 캡처하는 데 유용하다. thorvg/savers/png 지원을 고려하자. PNG 참고 자료는 thorvg/tools/svg2png에 있다.
  • 본문 scene 캡처 : PNG 정적 이미지 저장
    • save(Paint*) 오버로드 방향

PR #3874 구현 close

thorvg/thorvg#3874 “saver/png: support png saver” CLOSED(2026-06-04)

  • Static PNG saver를 savers/png에 추가하되, 인코딩 함수를 loaders/png/tvgLodePng에서 가져왔다.
    • 이때 meson에서 png_loader = png_loader or png_saver로 묶여, lodepng 구현이 loader 모듈 아래 있는 채로 saver가 그것을 끌어쓰는 의존이 생겼다.
    • 의존 방향 : saver → loader의 lodepng
  • codec을 통합하여 loader와 saver가 공유한다. common/tvgPngCodec.h/.cpp
    • PNG 로더 위치 : png/tvgLodePng → common/tvgPngCodec
    • 그 위에 saver를 구현

이슈 관계

  • 상위 core thorvg #3737 : PNG saver 새롭게 구현한다.
  • 하위
    • thorvg.web #139 : WebGL save2png 빈 이미지. core saver 구현 후 위에 바인딩을 얹자
    • thorvg.view #124 : web 과 동일 증상 web(#139)과 viewer(#124)는 증상 쪽이고, 해결은 core(#3737)에 PNG saver를 세우는 것. web에서 자체 encoder로 우회하는 길이 기술적으로 아주 막혀 있는 것은 아니지만, 이중 구현이라 근본 해결이 되지 못한다.

core PNG saver 구현 후 web

  1. thorvg.web이 PNG saver가 구현된 ThorVG 버전을 반영한다. (서브모듈 갱신 + wasm 재빌드)
  2. WASM 바인딩의 save()에 PNG 형식을 노출한다. (tvgWasmLottieAnimation.cpp:434save()가 현재 "gif"만 분기 → "png" 분기 추가, save2Png 구현)
  3. save2png()가 캔버스를 읽지 않고 MEMFS의 output.pngFS_readFile로 읽어 내려받도록 변경한다. (현재의 canvas.toBlob() 방식 폐기)

core PNG saver 구현방향

PR #3874 참고 현재 core 코드(savers/gif, loaders/png, renderer/tvgSaver.cpp) codec 배치, saver 방식, 빌드 배선이며, 주요 핵심은 codec 배치

1. codec 배치 : 통합 (common/tvgPngCodec)

lodepng를 common/tvgPngCodec.h/.cpp로 통합해 loader와 saver가 공유한다.

  • 필요한 쪽만 컴파일하고, 둘다 켜지면 공요부가 한 번만 빌드된다.
    • 디코드부 : THORVG_PNG_LOADER_SUPPORT
    • 인코드부 : THORVG_PNG_SAVER_SUPPORT
  • PNG 로더 : loaders/png/tvgLodePng → common/tvgPngCodec로 옮기는 리팩터 커밋
  • 그 위에 인코드 심볼과 saver를 올린다.
  • 인코딩은 lodepng로 한다.

2. saver 방식

GifSaver의 SW 렌더 패턴(SwCanvas::gen()target()update()/draw()/sync())을 정적 이미지용으로 축약

  • src/savers/png/tvgPngSaver.{h,cpp} 추가 struct PngSaver : SaveModule, Task
  • save(Animation*, ...) : GifSaver의 Paint 스텁과 대칭으로 “지원 안 함” 처리한다.
    • 정적 PNG에는 시간축이 없기 때문이다.
  • save(Paint*, bg, filename, quality) : paint->bounds()로 뷰 크기를 구하고(GifSaver :128-139처럼 음수 공간 컷오프와 zero-size 방어를 둔다), Task로 넘겨 run()에서 SwCanvasbgpaint를 add한 뒤 한 번만 렌더해 CPU 버퍼를 만들고, 그 버퍼를 tvgPngCodec의 인코드 함수로 파일에 기록한다.
    • quality는 lodepng 압축 레벨로 매핑할지 우선 무시할지는 정책 확정이 필요하다.
  • core : Paint를 받는 부분까지.
  • web save2Png 바인딩 : 현재 화면에서 사용 중인 scene의 Paint를 saver에 전달하도록 설계한다.

3. 빌드 배선

  • FileType::Png는 이미 enum에 정의돼 있어(src/common/tvgCommon.h:76) loader용 값을 saver 분기에서 재사용한다.
  • meson_options.txt의 savers 선택지(:18)를 ['', 'gif', 'png', 'all']로 확장
    • src/savers/meson.buildif png_saver / subdir('png')를 추가
    • src/savers/png/meson.build는 gif의 것을 그대로 본뜬다.
  • THORVG_PNG_SAVER_SUPPORT 매크로 정의
    • tvgSaver.cpp#ifdeftvgPngSaver.h include
  • saver 등록
    • _find(FileType)(src/renderer/tvgSaver.cpp:50)에 case FileType::Png 분기
    • _find(const char* filename)(:82)에 !strcmp(ext, "png") 분기
  • web wasm은 static 빌드라 이 static PngSaver가 그대로 붙는다.

질문 및 확인사항

  • #3737 할당/작업 가능한지 검토가 필요하다.

댓글

Discussion 원문