테스트 프로그램
- perf-test: 웹 캔버스(WASM 렌더러)의 퍼포먼스 테스터. 실제로 web canvas의 api를 테스트 하는게 아니라 렌더러로 동작.
- playground: 웹 캔버스 플레이그라운드. 예제를 코드로 볼 수 있고 직접 상호작용할 수 있음.
perf-test
작년에 참여했을때는 lottie-player 밖에 없었고, 이걸 테스트 하는 프로그램이었다. 하지만 이번에 webcanvas가 생기면서 렌더링을 lottie-player에서 webcanvas로 바꿨다. 실제로 lottie-player.d.ts 파일이 남아있다.
퍼포먼스 테스트 답게 여러 엔진을 선택할 수 있고 최대 100개의 애니메이션을 렌더링 해볼 수 있다. internal mode가 있어서 콘솔에서 this.__tvg.internal() 함수를 실행하면 버전 선택 및 벤치마크를 돌릴 수 있는 버튼이 보인다. 시드를 통해서 버전에 따라 퍼포먼스의 차이가 있는지 비교할 수 있고, 문제가 발생했을 때도 시드값을 통해 쉽게 재현할 수 있다.
구조는 page, viewer가 있고 page에서 위 스크린샷과 같이 그리드 형식으로 애니메이션을 보여준다. 재밌는 부분은 최적화를 위해 버츄얼 스크롤을 구현했다는 점이다. div안에 canvas가 있고 div는 애니메이션의 row의 총 높이 만큼의 높이를 가지고 있어서 스크롤이 생기지만 캔버스는 sticky 속성을 가지고 있어서 실제로 캔버스가 스크롤되지는 않는다.
<div
ref={scrollAreaRef}
style={{ height: gridHeight ? `${gridHeight}px` : undefined, position: 'relative' }}
>
<canvas
ref={canvasRef}
id="tvg-main-canvas"
onClick={handleCanvasClick}
style={{
position: 'sticky',
top: `${headerRef.current?.offsetHeight ?? 0}px`,
height: canvasCssHeight ? `${canvasCssHeight}px` : undefined,
}}
className={`cursor-pointer transition-opacity duration-300 block ${isLoading ? 'opacity-0' : 'opacity-100'}`}
title="Click any animation to open the detailed viewer"
/>
</div>
그리고 tick 함수를 살펴보면, scrollAreaRef와 canvasRef의 높이 차를 계산해서 row를 제거하는 모습을 볼 수 있다.
// 높이 차이 계산
const scrollRect = scrollEl.getBoundingClientRect();
const canvasRect = canvasEl.getBoundingClientRect();
const offsetGridToCanvas = scrollRect.top - canvasRect.top;
scrollOffsetRef.current = offsetGridToCanvas;
// visible grid Y range (in grid coords)
const visTopGrid = -offsetGridToCanvas;
const visBotGrid = visTopGrid + canvasRect.height;
const firstRow = Math.max(0, Math.floor(visTopGrid / cellSize));
const lastRow = Math.ceil(visBotGrid / cellSize) - 1;
const list = animsRef.current;
for (let i = 0; i < list.length; i++) {
const entry = list[i];
const row = Math.floor(i / cols);
const shouldShow = row >= firstRow && row <= lastRow;
if (entry.picture && shouldShow !== entry.visible) {
entry.visible = shouldShow;
if (shouldShow) tvgCanvas.add(entry.picture);
else tvgCanvas.remove(entry.picture);
}
if (shouldShow && entry.picture) {
entry.picture.translate(entry.posX, entry.gridY + offsetGridToCanvas + entry.yOff);
if (entry.info?.totalFrames > 0) {
entry.anim.frame((elapsed * entry.info.fps) % entry.info.totalFrames);
}
}
}
tvgCanvas.update().render();
높이 차이를 계산해서 tvgCanvas.remove를 통해서 캔버스에서 제거하고 entry.picture.translate를 통해서 스크롤에 맞게 움직이게 함으로써 버츄얼 스크롤을 구현하였다.
playground
webcanvas의 예제 쇼케이스이다. webcanvas에서 제공하고 있는 다양한 API를 직접 다뤄볼 수 있다.
구조는 page랑 showcase가 있다. lib/examples에 다양한 예제를 가지고 있고 각각 id, title, thumbnail 등 속성을 가지고 있어서 이를 기반으로 렌더링한다. showcase에서는 canvas랑 모나코 에디터를 분리해서 보여주고 있다. example에서 코드를 불러와서 모나코 에디터에 넣고 있고, 모나코 에디터에서 코드를 수정하면 이를 기반으로 캔버스에서 렌더링 해준다.
모나코 에디터에서 가져온 코드를 어떻게 캔버스에서 그려주는지 궁금해서 한번 알아보았다. CanvasePreview.tsx에서 runCode를 보면 되는데, 해당 부분에서 애니메이션 멈추고 캔버스 클리어하고 등등 코드를 실행하기전에 클린업하는 과정을 한다. 중요하게 볼 지점은 다음과 같다.
// Transform code: strip imports, init calls, and canvas creation
// This is smart and works with any variable names
const { transformCodeForExecution } = await import('@/lib/code-transformer');
const executableCode = transformCodeForExecution(code);
....
// Create a function context with pre-loaded modules
// The user code will have access to TVG, canvas, and requestAnimationFrame
const executeFunction = new Function(
'TVG',
'canvas',
'requestAnimationFrame',
'performance',
'console',
'fetch',
executableCode
);
// Execute with pre-loaded context
await executeFunction(TVG, canvas, wrappedRAF, performance, console, cachedFetch);
인자로 받은 코드(부모로 부터 넘어오고 초기 값 또는 모나코 에디터 값)를 lib에 있는 transformCodeForExecution를 통해서 정제해준다. 이 값은 나중에 executeFunction에서 실제 실행될 코드이다. executeFunction을 new Function으로 선언해서 안전하게 컴포넌트에서 정의한 값에 접근하도록 한다. 특히 TVG, canvas를 직접 넘겨서 코드가 바뀔때마다 thorvg canvas를 다시 마운트 안하도록 해준다. 그래서 모나코 에디터에서 값을 수정해도 기존에 초기화한 tvg를 사용할 수 있다.
새로운 예제는 탑 쌓는 게임을 하나 만들어 보려고 한다. 옛날에 군대에서 위병소 컴퓨터로 만들었던 게임인데 재미에 비해서 구현이 생각보다 간단하다. 도전심도 생기고.
큐에 막대를 넣어놓고 막대는 큐의 마지막 막대 위에서 좌우로 움직인다. 이때 스페이스바 를 누르면 막대가 멈추고 해당 위치랑 마지막 막대의 위치를 비교해서 새로운 너비가 결정된다. 그리고 너비가 0이 되면 게임 오버.
여기서 다음 코드를 실행해서 게임을 플레이할 수 있다. (해당 사이트에서 폰트를 못 불러오는 문제가 있는 듯 하다.) 브랜치
// Tower Stack - a small game built out of plain Shapes.
// SPACE (or click the canvas) drops the moving bar.
// Whatever hangs over the bar below is sliced off, and the rest becomes the new top.
import { init } from '@thorvg/webcanvas';
const TVG = await init({
renderer: 'gl',
locateFile: (path) => '/webcanvas/' + path.split('/').pop()
});
const SIZE = 600;
const canvas = new TVG.Canvas('#canvas', {
width: SIZE,
height: SIZE,
});
//The playground re-executes this file on every edit, so tear down the previous
//session first. Without this, listeners pile up and one keypress drops many bars.
globalThis.__towerStack?.abort();
const session = new AbortController();
globalThis.__towerStack = session;
const BAR_W = 250; //width of the very first bar
const BAR_H = 30; //height of every bar
const MAX_QUEUE = 5; //bars kept on screen; pushing a 6th drops the front one
const BASE_Y = SIZE - BAR_H; //the bottom bar rests on the canvas floor
const SPEED = 160; //bar speed on the first row, in px/s
const SPEED_GAIN = 0.05; //share of SPEED added per row: 2x by row 20
//Every bar lives in this queue. The new bar always starts one row above
//queue[queue.length - 1] and is exactly as wide as it. Once the queue is full each
//push shifts the front out, so the tower appears to climb while the bar stays at a
//fixed height - no camera maths needed.
let queue, bar, slide, score, over;
//The canvas keeps its scene between frames, so build it once here and let the loop
//just move the shapes that changed.
const sky = new TVG.Shape();
sky.appendRect(0, 0, SIZE, SIZE);
sky.fill(163, 209, 240);
canvas.add(sky);
//One shape per row: the queued bars plus the moving one above them
const bars = [];
for (let i = 0; i <= MAX_QUEUE; i++) {
const shape = new TVG.Shape();
shape.fill(255, 118, 92);
canvas.add(shape);
bars.push(shape);
}
//ThorVG ships no built-in font, so the game-over label needs one. Loading it without
//awaiting keeps the game playable from the first frame.
let overText = null;
fetch('/fonts/PublicSans-Regular.ttf')
.then(res => res.arrayBuffer())
.then(buf => {
TVG.Font.load('ui', new Uint8Array(buf), { type: 'ttf' });
overText = new TVG.Text();
overText.font('ui').fontSize(36).fill(24, 52, 84)
.align(0.5, 0.5).translate(SIZE / 2, SIZE / 2);
canvas.add(overText);
});
//Screen Y of row i, including the offset left over from the last queue shift
function rowY(i) {
return BASE_Y - i * BAR_H + slide;
}
function reset() {
queue = [{ x: (SIZE - BAR_W) / 2, w: BAR_W }];
slide = 0;
score = 0;
over = false;
spawn();
}
function spawn() {
const last = queue[queue.length - 1];
//Pick the starting edge at random so the timing can't be memorised
const fromLeft = Math.random() < 0.5;
bar = {
x: fromLeft ? 0 : SIZE - last.w,
w: last.w,
dir: fromLeft ? 1 : -1,
speed: SPEED * (1 + score * SPEED_GAIN), //ramps up gently with the score
};
}
function drop() {
if (over) { reset(); return; }
//Keep only the part that overlaps the bar below
const last = queue[queue.length - 1];
const left = Math.max(bar.x, last.x);
const right = Math.min(bar.x + bar.w, last.x + last.w);
if (right - left <= 0) { bar = null; over = true; return; }
queue.push({ x: left, w: right - left });
score++;
//Full queue: drop the front bar and slide everything down one row
if (queue.length > MAX_QUEUE) {
queue.shift();
slide = -BAR_H;
}
spawn();
}
function update(dt) {
slide -= slide * Math.min(1, dt * 12); //ease the shift offset back to zero
if (!bar) return;
bar.x += bar.dir * bar.speed * dt;
if (bar.x <= 0) { bar.x = 0; bar.dir = 1; }
else if (bar.x + bar.w >= SIZE) { bar.x = SIZE - bar.w; bar.dir = -1; }
}
function draw() {
//Nothing is ever added to or removed from the scene: a slot with no bar is just
//hidden, and a live one is re-laid out. reset() drops the old path but keeps the
//fill, so the colour only has to be set once at build time.
for (let i = 0; i < bars.length; i++) {
const row = i < queue.length ? queue[i] : (i === queue.length ? bar : null);
bars[i].visible(row !== null);
if (row) bars[i].reset().appendRect(row.x, rowY(i), row.w, BAR_H);
}
if (overText) {
overText.visible(over);
if (over) overText.text('GAME OVER ' + score);
}
canvas.update();
canvas.render();
}
//Input
const el = document.querySelector('#canvas');
addEventListener('keydown', (e) => {
if (e.code !== 'Space') return;
e.preventDefault();
drop();
}, { signal: session.signal });
el.addEventListener('pointerdown', drop, { signal: session.signal });
reset();
//Run the game loop
let last = 0;
function frame(now) {
if (session.signal.aborted) return;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
update(dt);
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
댓글
Discussion 원문