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로 그린다. - 노이즈는 변위가 읽는 알파 채널 하나만, 그것도 층이 실제로 그려진 범위에서 4px마다 한 번 계산하고 사이는 보간한다. 노이즈 주기가 수십 픽셀이라 눈으로는 차이가 없다. 호출 수는 네 채널 전부 계산할 때의 1/64 아래로 줄었다.
- 옮길 거리는 정수로 반올림하고 가장 가까운 픽셀을 가져온다. resvg도 이렇게 해서 톱니 같은 가장자리가 그대로 남는다.
- SVG 필터는 기본으로 linearRGB 색공간을 8비트로 오간다. 그때 어두운 색이 살짝 더 어두워지는 것까지 256칸짜리 표로 맞췄다.
- PNG도 직접 만든다. 2400×2400 RGBA면 23MB라 사본을 한 번 더 만들면 128MB가 금방 찬다. 그래서 한 행씩
CompressionStream('deflate')에 흘려 보낸다.
노이즈 함수는 SVG 1.1 명세 부록에 실린 참조 구현을 그대로 옮겼다. 난수를 명세와 같은 순서로 뽑아야 같은 무늬가 나와서, 안 쓰는 R·G·B용 값도 초기화 때 만들어서 버린다. 버리는 건 초기화 한 번뿐이고 픽셀마다 드는 비용은 없다.
결과
로컬 Mac에서 프레임 한 장(필터 3개)을 resvg-wasm 2.6.2로 그린 시간이다. 세 번 재서 가운뎃값을 적었다.
| 크기 | 필터 없음 | resvg 필터 | raster.js |
|---|---|---|---|
| 1080px | 0.11초 | 0.89초 | 0.20초 |
| 2400px | 0.45초 | 4.31초 | 0.88초 |
Workers에서는 15초 넘게 걸리던 렌더가 1초 안팎이 됐다. 60초로 올려 둔 한도는 이제 여유분이다.
빨라졌어도 그림이 달라지면 못 쓴다. 이미 공유됐거나 굿즈로 팔린 그림이 있어서다. 그래서 resvg 결과와 픽셀 단위로 비교했는데 채널 값이 16 넘게 차이 나는 픽셀은 2400px 프레임에서 0.15%였다.

선글라스 하이라이트를 8배로 키워 보면 왼쪽(resvg)과 가운데(raster.js)의 톱니 모양이 같다. 오른쪽에 빨갛게 찍힌 게 다른 픽셀인데 가장자리에서 반올림이 한 픽셀 다르게 떨어진 곳들이다.
이 비교는 npm test에 넣어 뒀다. 반투명 겹침, 꾸미기 글자, 가로형 OG 카드까지 네 경우에서 다른 픽셀이 1%를 넘으면 실패한다.
고칠 곳은 아무래도 resvg
이런 우회 코드는 언젠가 지우는 게 맞는 것 같다. 그래서 재현 SVG와 측정값을 붙여 resvg에 이슈를 올렸다(linebender/resvg#1162). 뒤에서 읽는 채널만 계산하자는 제안이고 받아들여지면 PR도 보낼 생각이다.
재다가 하나 더 봤다. 같은 SVG를 더 새 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 쪽 답을 기다려 보려고 한다.