Yunseon Hong

archive / / #개발

Wobblefolk - 손떨림 필터 하나에 CPU 15초

Yunseon Hong · 8분 읽기

굿즈용 2400px 이미지를 Cloudflare Workers에서 그리는 데 CPU가 15초 넘게 들었다. 범인은 SVG 손떨림 필터였고, 그 필터만 직접 구현해 1초 안팎으로 줄였다.

Wobblefolk을 열면서 폰케이스 주문 버튼을 처음 누르면 인쇄 이미지를 그리는 데 10초 넘게 걸린다고 적었다. 그때는 먼저 그려 두는 걸로 넘어갔는데 같은 날 결국 다시 터졌다.

Zazzle에 넘길 2400px PNG는 서버에서 그린다. Cloudflare Workers 위에서 resvg(Rust로 만든 SVG 렌더러)를 wasm으로 돌린다.

이 한 장에 CPU가 17.5초 들었고 워커에 걸어 둔 15초 한도를 넘겨 exceededCpu로 죽었다. 두 장을 동시에 그리면 이번엔 메모리 128MB를 넘겼다.

일단 한도를 60초로 올리고 한 장씩 그리게 바꿔서 급한 불은 껐다. 그런데 그림에서 필터만 빼고 그려보니 0.2초면 끝나더라. 15초 가운데 거의 전부가 필터 하나였다.

손떨림은 노이즈 한 장과 픽셀 옮기기

Wobblefolk 캐릭터는 선이 조금씩 울퉁불퉁하다. 이 흔들림은 SVG 필터 두 단계로 만든다. feTurbulence가 구름 같은 노이즈를 만들고 feDisplacementMap이 그 값만큼 원본 픽셀을 옮긴다.

<filter id="w" x="-5%" y="-5%" width="110%" height="110%">
  <feTurbulence type="fractalNoise" baseFrequency="0.035" numOctaves="2" seed="229"/>
  <feDisplacementMap in="SourceGraphic" scale="5"/>
</filter>

필터 없이 그리면 이렇게 매끈하다.

필터 없이 그린 캐릭터. 선글라스를 쓴 하얀 캐릭터의 윤곽선이 매끈하다.

feTurbulence가 만든 노이즈의 알파 채널을 회색으로 보면 이렇다. 밝을수록 값이 크고 seed가 같으면 늘 같은 무늬가 나온다.

노이즈의 알파 채널을 회색으로 나타낸 그림. 구름 같은 얼룩 무늬다.

이 값만큼 픽셀을 옮기면 윤곽선이 몇 픽셀씩 흔들린다.

손떨림 필터를 건 캐릭터. 같은 그림인데 윤곽선이 조금씩 울퉁불퉁하다.

옮기는 거리는 scale × (노이즈 값 - 0.5)다. xChannelSelector·yChannelSelector를 안 적으면 x, y 둘 다 알파 채널을 읽는다. 이게 뒤에서 문제가 된다.

resvg는 네 채널을 다 계산한다

resvg의 노이즈 코드를 열어보니 픽셀마다 R·G·B·A 네 채널을 전부 계산하고 있었다.

pixel.r = turb(0);
pixel.g = turb(1);
pixel.b = turb(2);
pixel.a = turb(3);

뒤에 오는 변위 필터는 알파만 읽으니 가장 비싼 계산의 3/4은 버려지는 셈이다. 2400px이면 필터 하나에 노이즈 함수를 2,300만 번(2400 × 2400 × 4) 부르고 그중 1,700만 번은 버려진다.

wasm이라 더 느린 것도 있는 것 같다. 같은 파일을 로컬에서 resvg 네이티브 CLI(0.45.1)로 그리면 1.6초, wasm 패키지(@resvg/resvg-wasm 2.6.2)로는 4.3초였다.

필터만 직접 건다

resvg를 버리진 않았다. 도형과 글자는 그대로 resvg가 그리고 느린 필터 하나만 비트맵에서 직접 건다. 코드는 raster.js 한 파일, 200줄 남짓이다.

필터 걸린 층만 따로 흔들고, 노이즈는 알파만 계산한다 필터 걸린 층만 따로 흔들고, 노이즈는 알파만 계산한다 층마다 반복해 그리는 순서대로 위에 쌓는다 SVG 한 장캐릭터, 프레임 배경, 꾸미기 글자 그리는 순서대로 층 나누기다른 층은 display=none으로 숨긴다 필터 없는 묶음배경색, 글자 필터 걸린 요소 하나캐릭터, 배경 무늬 resvg로 그리기평소와 같다 resvg로 그리기filter 속성만 뺀다 노이즈: 알파 채널만그린 범위만, 4px 격자에서 보간 그대로 겹치기알파 미리곱 합성 정수 픽셀만큼 옮겨 겹치기최근접 샘플, linearRGB 보정 PNG 인코딩행마다 deflate 스트림, 사본 없음

순서는 이렇다.

  1. SVG를 그리는 순서대로 ‘필터 없는 묶음’과 ‘필터 걸린 요소 하나’로 자른다. 층마다 나머지는 display="none"으로 숨기고 resvg로 그린다.
  2. 노이즈는 변위가 읽는 알파 채널 하나만, 그것도 층이 실제로 그려진 범위에서 4px마다 한 번 계산하고 사이는 보간한다. 노이즈 주기가 수십 픽셀이라 눈으로는 차이가 없다. 호출 수는 네 채널 전부 계산할 때의 1/64 아래로 줄었다.
  3. 옮길 거리는 정수로 반올림하고 가장 가까운 픽셀을 가져온다. resvg도 이렇게 해서 톱니 같은 가장자리가 그대로 남는다.
  4. SVG 필터는 기본으로 linearRGB 색공간을 8비트로 오간다. 그때 어두운 색이 살짝 더 어두워지는 것까지 256칸짜리 표로 맞췄다.
  5. PNG도 직접 만든다. 2400×2400 RGBA면 23MB라 사본을 한 번 더 만들면 128MB가 금방 찬다. 그래서 한 행씩 CompressionStream('deflate')에 흘려 보낸다.

노이즈 함수는 SVG 1.1 명세 부록에 실린 참조 구현을 그대로 옮겼다. 난수를 명세와 같은 순서로 뽑아야 같은 무늬가 나와서, 안 쓰는 R·G·B용 값도 초기화 때 만들어서 버린다. 버리는 건 초기화 한 번뿐이고 픽셀마다 드는 비용은 없다.

결과

로컬 Mac에서 프레임 한 장(필터 3개)을 resvg-wasm 2.6.2로 그린 시간이다. 세 번 재서 가운뎃값을 적었다.

크기필터 없음resvg 필터raster.js
1080px0.11초0.89초0.20초
2400px0.45초4.31초0.88초

Workers에서는 15초 넘게 걸리던 렌더가 1초 안팎이 됐다. 60초로 올려 둔 한도는 이제 여유분이다.

빨라졌어도 그림이 달라지면 못 쓴다. 이미 공유됐거나 굿즈로 팔린 그림이 있어서다. 그래서 resvg 결과와 픽셀 단위로 비교했는데 채널 값이 16 넘게 차이 나는 픽셀은 2400px 프레임에서 0.15%였다.

선글라스 하이라이트를 8배로 키운 비교. 왼쪽 resvg, 가운데 raster.js, 오른쪽은 다른 픽셀만 빨갛게 찍은 그림이다.

선글라스 하이라이트를 8배로 키워 보면 왼쪽(resvg)과 가운데(raster.js)의 톱니 모양이 같다. 오른쪽에 빨갛게 찍힌 게 다른 픽셀인데 가장자리에서 반올림이 한 픽셀 다르게 떨어진 곳들이다.

이 비교는 npm test에 넣어 뒀다. 반투명 겹침, 꾸미기 글자, 가로형 OG 카드까지 네 경우에서 다른 픽셀이 1%를 넘으면 실패한다.

고칠 곳은 아무래도 resvg

이런 우회 코드는 언젠가 지우는 게 맞는 것 같다. 그래서 재현 SVG와 측정값을 붙여 resvg에 이슈를 올렸다(linebender/resvg#1162). 뒤에서 읽는 채널만 계산하자는 제안이고 받아들여지면 PR도 보낼 생각이다.

재다가 하나 더 봤다. 같은 SVG를 더 새 resvg(네이티브 0.45.1)로 그리면 흔들림이 훨씬 커진다.

같은 SVG를 두 버전으로 그린 비교. 왼쪽 resvg-wasm 2.6.2는 윤곽이 살짝 흔들리고, 오른쪽 resvg 0.45.1은 크게 일그러졌다.

왼쪽이 지금 운영에서 쓰는 resvg-wasm 2.6.2이고 오른쪽이 resvg 0.45.1이다. 명세대로 옮긴 raster.js의 결과도 왼쪽과 같다. resvg에는 변위 scale이 두 번 곱해지는 버그가 보고돼 있어서(#1160) 오른쪽은 그 탓으로 보인다.

렌더러 버전만 올려도 이미 판 그림이 바뀔 수 있다는 얘기다. resvg-wasm을 올릴 때는 픽셀 비교 테스트부터 통과시켜야겠다.

남은 건 두 가지. 꾸미기 글자가 많으면 층마다 전체 크기로 resvg를 열 번 남짓 부르는데 느려지면 필터 층을 그려지는 범위로 잘라 그릴 생각이다. 그리고 raster.js는 우리 SVG에만 맞춘 정규식 파서라 범용으로 내놓을 물건은 아니다. 그래서 따로 공개하기보다 resvg 쪽 답을 기다려 보려고 한다.

shareXFacebookLinkedIn

keep reading

datepostcategory
낙서 아바타 서비스 Wobblefolk을 열었다#개발
Cloudflare 무료 플랜의 rate limit으로는 하루 1,000건을 못 지킨다#개발
ccbar를 Claude Code 플러그인 디렉터리에 제출했다#개발