웹 트랙 2주차 과제로 perf-test와 playground를 로컬에서 빌드해 구동해보고, Playground에 다각형 자르기 예제를 하나 추가했습니다.
빌드와 구동
두 앱 모두 thorvg.web 저장소 안에 있는데, 루트의 pnpm 워크스페이스와 따로 yarn을 씁니다. 각 디렉터리에서 따로 설치해야 했습니다.
cd perf-test
yarn install
yarn dev # 3000 포트
cd playground
yarn install
yarn dev # 3001 포트
둘 다 Next.js 기반이고 별 문제 없이 떴습니다.
perf-test
상단에서 렌더러(Software / WebGL / WebGPU), 애니메이션 개수, 크기를 고르고 Apply를 누르면 그 조건으로 Lottie를 한 화면에 깔아줍니다. 좌하단에 FPS, MS, 메모리가 실시간으로 찍힙니다.
WebGL로 20개, 150px에서 60 FPS, 49 MB가 나왔습니다.
URL에 ?seed=...가 붙는 게 눈에 띄었습니다. 어떤 Lottie 조합이 뽑혔는지를 URL에 넣어두는 것 같습니다. 같은 seed로 다시 열면 같은 조합이 나올 테니 렌더러를 바꿔가며 비교할 때 쓰라는 것 아닐까 싶습니다.
package.json에 yarn bench가 있고 playwright가 들어 있는 걸 보면 브라우저를 자동으로 띄워서 벤치마크를 돌리는 것도 되는 모양인데, 이번엔 못 돌려봤습니다.
playground
WebCanvas 예제들이 카드로 쭉 나열되고, 카드를 누르면 왼쪽에 캔버스 오른쪽에 코드 편집기가 있는 화면이 열립니다. 코드를 고치면 바로 반영됩니다.
WebCanvas와 어떻게 연결되어 있나
과제에서 물어본 부분이라 여기를 좀 봤습니다.
두 앱의 package.json에 이렇게 되어 있습니다.
"@thorvg/webcanvas": "latest"
그리고 playground/components/CanvasPreview.tsx의 첫 줄이 이렇습니다.
import wasmUrl from "../node_modules/@thorvg/webcanvas/dist/thorvg.wasm";
npm에 올라간 패키지를 받아서 씁니다. 같은 저장소 안의 packages/webcanvas/dist/가 아니었습니다.
웹 1주차 때 webcanvas를 빌드하느라 한참 걸렸는데, 그 결과물이 이 두 앱이랑은 상관이 없었던 셈입니다. 개발 가이드에 “로컬 빌드를 다른 프로젝트에서 테스트하려면 npm install /path/to/packages/webcanvas로 교체한다”는 문장이 있었는데, 왜 그런 안내가 있는지 이제 알 것 같습니다. 그냥 두면 npm 버전을 보니까 코어를 고쳐도 반영이 안 되는 거였습니다.
Playground 화면 아래에 ThorVG v1.1.0 · GL이 뜨는데 이것도 npm에서 받은 버전인 것 같습니다.
Playground가 예제 코드를 실행하는 방식
여기가 생각했던 거랑 달랐습니다.
예제 파일을 열어보면 초기화 코드가 들어 있습니다.
import { init } from '@thorvg/webcanvas';
const TVG = await init({ renderer: 'gl', ... });
const canvas = new TVG.Canvas('#canvas', { width: 600, height: 600 });
그래서 예제마다 각자 초기화를 하는 줄 알았는데, lib/code-transformer.ts가 실행 전에 이 부분을 지웁니다. 그리고 CanvasPreview가 미리 만들어둔 걸 넣어줍니다.
const executeFunction = new Function(
'TVG', 'canvas', 'requestAnimationFrame', 'performance', 'console', 'fetch',
executableCode
);
즉 예제에 적힌 초기화 코드는 보여주기용이고, 실제로는 Playground가 들고 있는 TVG랑 Canvas가 들어갑니다. 화면 위에서 렌더러를 SW/GL/WG로 바꿀 수 있는 것도 이 구조 때문인 것 같습니다. 예제가 렌더러를 직접 고르는 게 아니라 값만 뽑아서 공용 초기화에 넘기는 식으로 보입니다.
예제를 복사해서 다른 데 붙여도 돌아가게 초기화 코드는 남겨두고, 실행은 공용 환경에서 하는 절충 아닐까 싶습니다.
화면을 갱신하는 두 가지 방식
예제를 둘러보다가 이름이 비슷한 두 개가 서로 다른 방식을 쓰는 걸 봤습니다.
update.ts는 매 프레임 캔버스를 비우고 Shape를 새로 만듭니다.
canvas.clear();
const shape = new TVG.Shape();
// ... 모양과 색을 다시 지정
canvas.add(shape);
canvas.render();
direct-update.ts는 Shape를 계속 들고 있으면서 경로만 다시 그립니다.
solid.reset();
solid.appendRect(...);
canvas.update();
canvas.render();
direct-update.ts 쪽에 //fill property will be retained라는 주석이 있습니다. 한 번 정한 색은 그대로 두고 바뀌는 것만 갱신한다는 뜻인 것 같습니다.
같은 걸 만드는 두 가지 길이 예제로 나란히 들어 있는 게 재밌었습니다.
커스텀 예제: Polygon Slicing
도형을 드래그로 그은 직선을 따라 두 조각으로 나누는 예제를 만들었습니다. 잘린 조각은 사라지지 않고 남아서 다시 자를 수 있고, 잘릴 때마다 절단선의 수직 방향으로 밀려나면서 감속해 멈춥니다.
공통 2주차 과제에서 비슷한 걸 C++로 만들었어서, 이번엔 알고리즘만 TypeScript로 옮기고 WebCanvas API에 맞췄습니다. 해커톤에서 쓸 부분이라 웹에서도 되는지 확인해보고 싶기도 했습니다.
자르는 방법
반평면 클리핑(Sutherland-Hodgman)을 씁니다. 자르는 직선을 기준으로 평면이 둘로 나뉘는데, 도형의 꼭짓점을 순서대로 돌면서 남길 쪽 점만 모읍니다. 한 점은 남기는 쪽인데 다음 점은 버리는 쪽이면 그 변이 선을 넘어간 거라서 교차점을 계산해 끼워넣습니다.
어느 쪽인지는 외적 부호로 봅니다.
function side(a, b, p) {
return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
}
한쪽 부호로 한 번, 반대로 한 번 돌리면 조각 두 개가 나옵니다.
볼록 도형을 직선으로 자르면 두 조각도 볼록이라, 오각형으로 시작하면 여러 번 잘라도 이 방법의 전제가 안 깨집니다.
WebCanvas 쪽
계산은 전부 그냥 자바스크립트고, WebCanvas는 결과를 그리는 데만 씁니다.
const shape = new TVG.Shape();
shape.moveTo(piece.outline[0].x, piece.outline[0].y);
for (let i = 1; i < piece.outline.length; i++) {
shape.lineTo(piece.outline[i].x, piece.outline[i].y);
}
shape.close();
shape.fill(piece.r, piece.g, piece.b, 255);
shape.stroke({ width: 2, color: [14, 14, 18, 255] });
canvas.add(shape);
네이티브랑 다른 게 하나 있었습니다. C++에서는 strokeFill()과 strokeWidth()가 따로였는데 WebCanvas에서는 stroke({ width, color }) 하나로 합쳐져 있습니다. 그냥 이름만 바꾸면 되는 줄 알고 썼다가 안 돼서 stroke.ts 예제를 열어보고 알았습니다.
마우스 입력은 lottie-interaction.ts를 참고했습니다. 캔버스 요소에 pointerdown / pointermove / pointerup을 붙이고, 이벤트 좌표를 캔버스 좌표로 바꿔서 씁니다.
function position(e) {
const rect = el.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / rect.width * SIZE,
y: (e.clientY - rect.top) / rect.height * SIZE,
};
}
캔버스가 화면에서 확대·축소될 수 있으니 rect.width로 비율을 맞춰야 좌표가 안 어긋난다고 합니다.
갱신은 update.ts 쪽 방식으로 갔습니다. 매 프레임 canvas.clear() 하고 조각들을 다시 그립니다.
예제 등록
lib/examples/에 파일을 만들고 lib/examples/index.ts에 두 줄을 추가하면 목록에 나타납니다.
import { polygonSlicingExample } from './polygon-slicing';
pathExample,
polygonSlicingExample,
radialGradientExample,
배열에 “alphabetically sorted by title”이라는 주석이 있어서 어디 넣어야 하나 좀 봤는데, 변수명이 아니라 title 기준이었습니다. basicShapesExample이 S 자리에 있는 게 title이 “Shapes”라서였습니다.
thumbnail은 types.ts에서 선택 항목이길래 처음엔 안 넣었습니다. 그런데 목록에서 제 것만 이미지 없이 기본 아이콘으로 나와서 결국 만들었습니다. 기존 썸네일을 하나 열어보니 1050×1050이라 크기를 맞췄습니다.
아직 모르는 것
- 조각이 수백 개가 됐을 때도 괜찮은지 확인 못 했습니다. 지금은 매 프레임 모든 Shape를 새로 만들고 있어서 정지한 조각까지 다시 만드는 낭비가 있습니다.
direct-update.ts방식이 나을 것 같은데 시간이 없어서 못 해봤습니다. - perf-test의
yarn bench를 못 돌려봤습니다. - perf-test에서 애니메이션 개수를 늘렸을 때 그 개수가 매 프레임 다 그려지는 건지, 화면에 보이는 것만 그려지는 건지 확인 못 했습니다.
- WebGL이랑 Software가 FPS가 얼마나 차이나는지 비교 안 해봤습니다.
new Function으로 예제 코드를 실행하는 부분은 대충 무슨 일이 일어나는지만 알겠고, 왜 굳이 이렇게 했는지는 잘 모르겠습니다.
마치며
두 앱이 npm 패키지를 쓴다는 걸 안 게 이번에 제일 도움이 됐습니다. 웹 1주차에 빌드한 결과물이랑 이어져 있는 줄 알았는데 아니었습니다.
예제를 추가하는 것 자체는 파일 하나에 등록 두 줄이라 생각보다 간단했습니다. 다만 어떤 API가 있는지는 기존 예제를 하나씩 열어보면서 확인해야 했습니다. canvas.remove()가 있을 줄 알고 썼다가 안 돼서 찾아보니 예제 어디에도 안 쓰이고 있었고, canvas.clear()를 쓰더군요.
돌아가게 만드는 거랑 저장소에 맞게 넣는 게 다른 일이라는 것도 느꼈습니다. 들여쓰기가 2칸인지 4칸인지, 파일 끝에 개행이 있는지 같은 건 동작에는 상관없는데 다른 예제들이랑 나란히 놓이면 티가 납니다. 커밋 전에 git diff를 한 번 보고 넘어가야겠습니다.
댓글
Discussion 원문