grid와 ruler를 canvas에 그리기
무한 캔버스에서 grid와 ruler는 장식이 아닙니다. 사용자가 지금 어느 좌표계를 보고 있는지 알려주는 기준선입니다.
CSS에서는 반복 배경으로 grid를 만들 수 있습니다. Canvas/WebGL/WebGPU에서는 camera에 맞춰 직접 그립니다.
grid는 world 기준이어야 한다
화면 기준으로 32px마다 선을 그리면 pan할 때 grid가 문서와 함께 움직이지 않습니다. 편집기 grid는 보통 world 기준입니다.
world x = ..., -100, 0, 100, 200, ...
world y = ..., -100, 0, 100, 200, ...
이 world 선들을 camera로 screen 좌표에 변환해서 그립니다.
const screenX = (worldX - camera.x) * camera.zoom;
matrix helper를 쓰면 grid도 다른 도형과 같은 좌표 변환을 씁니다.
function worldXToScreen(worldX, camera) {
return transformPoint(cameraToScreenMatrix(camera), { x: worldX, y: 0 }).x;
}
function worldYToScreen(worldY, camera) {
return transformPoint(cameraToScreenMatrix(camera), { x: 0, y: worldY }).y;
}
보이는 영역의 world bounds를 구한다
먼저 현재 viewport가 world에서 어디부터 어디까지 보는지 계산합니다.
function visibleWorldBounds(camera, viewport) {
return {
left: camera.x,
top: camera.y,
right: camera.x + viewport.width / camera.zoom,
bottom: camera.y + viewport.height / camera.zoom
};
}
그다음 이 범위 안에 들어오는 grid line만 그립니다.
first = floor(left / step) * step
last = ceil(right / step) * step
zoom에 따라 step을 바꾼다
zoom이 작아지면 grid가 너무 촘촘해지고, zoom이 커지면 너무 듬성듬성해집니다. 그래서 screen에서 보기 좋은 간격을 유지하도록 world step을 고릅니다.
target screen gap ~= 64px
world step = niceNumber(target screen gap / zoom)
niceNumber는 1, 2, 5, 10 계열 값을 고르는 함수로 만들 수 있습니다. ruler tick도 같은 원리를 씁니다.
function niceStep(rawStep) {
const exponent = Math.floor(Math.log10(rawStep));
const base = Math.pow(10, exponent);
const fraction = rawStep / base;
if (fraction <= 1) return base;
if (fraction <= 2) return 2 * base;
if (fraction <= 5) return 5 * base;
return 10 * base;
}
function gridStepForZoom(zoom, targetScreenGap = 64) {
return niceStep(targetScreenGap / zoom);
}
Canvas 2D로 grid를 그리는 코드
grid renderer는 camera의 좋은 테스트입니다. pan하면 선이 부드럽게 밀리고, zoom하면 step이 적절히 바뀌어야 합니다.
function drawGrid(ctx, camera, viewport) {
const bounds = visibleWorldBounds(camera, viewport);
const step = gridStepForZoom(camera.zoom);
const firstX = Math.floor(bounds.left / step) * step;
const lastX = Math.ceil(bounds.right / step) * step;
const firstY = Math.floor(bounds.top / step) * step;
const lastY = Math.ceil(bounds.bottom / step) * step;
ctx.strokeStyle = "rgba(35, 45, 55, 0.14)";
ctx.lineWidth = 1;
for (let x = firstX; x <= lastX; x += step) {
const sx = Math.round((x - camera.x) * camera.zoom) + 0.5;
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx, viewport.height);
ctx.stroke();
}
for (let y = firstY; y <= lastY; y += step) {
const sy = Math.round((y - camera.y) * camera.zoom) + 0.5;
ctx.beginPath();
ctx.moveTo(0, sy);
ctx.lineTo(viewport.width, sy);
ctx.stroke();
}
}
+ 0.5는 1px 선을 pixel boundary에 맞춰 더 또렷하게 보이게 하는 Canvas 2D 팁입니다. WebGL에서는 clip space와 backing store 크기로 같은 문제를 다룹니다.
오늘의 핵심
grid와 ruler는 camera를 테스트하는 좋은 도구입니다.
world 기준으로 tick을 고른다.
camera로 screen 좌표에 보낸다.
label은 world 값으로 표시한다.
이 셋이 맞으면 pan/zoom과 좌표 변환이 건강하다는 신호입니다.