cursor anchored zoom
무한 캔버스에서 zoom은 단순히 zoom *= 1.1로 끝나지 않습니다. 사용자는 마우스 커서 아래의 지점이 그대로 남아 있기를 기대합니다.
줌 전에도 커서 아래에 있던 world point
줌 후에도 같은 screen point 아래에 있어야 한다
이것을 cursor anchored zoom이라고 부르겠습니다.
나쁜 zoom은 화면을 미끄러뜨린다
camera 위치를 그대로 두고 zoom만 바꾸면 화면 왼쪽 위를 기준으로 확대됩니다.
camera.zoom *= 1.1;
이러면 커서 아래의 도형이 커서에서 밀려납니다. 사용자는 자신이 보고 있던 지점을 잃어버립니다.
고정할 world point를 먼저 구한다
먼저 zoom 전 커서 아래의 world 좌표를 구합니다.
const before = screenToWorld(cursor, camera);
그다음 zoom을 바꾸고, 같은 cursor screen 좌표가 다시 before를 가리키도록 camera 위치를 계산합니다.
const newZoom = camera.zoom * zoomFactor;
camera.x = before.x - cursor.x / newZoom;
camera.y = before.y - cursor.y / newZoom;
camera.zoom = newZoom;
공식은 짧지만 의미는 분명합니다.
world = screen / zoom + camera
camera = world - screen / zoom
실제 함수는 camera를 직접 mutate하지 않고 새 camera를 반환하는 편이 undo/debug에 좋습니다.
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function zoomAt(camera, screenPoint, zoomFactor) {
const before = screenToWorld(screenPoint, camera);
const nextZoom = clamp(
camera.zoom * zoomFactor,
camera.minZoom ?? 0.05,
camera.maxZoom ?? 32
);
return {
...camera,
x: before.x - screenPoint.x / nextZoom,
y: before.y - screenPoint.y / nextZoom,
zoom: nextZoom
};
}
검증도 코드로 할 수 있습니다. zoom 후에도 같은 world point가 같은 screen point로 돌아와야 합니다.
const nextCamera = zoomAt(camera, cursor, 1.2);
const afterScreen = worldToScreen(before, nextCamera);
console.assert(Math.abs(afterScreen.x - cursor.x) < 0.001);
console.assert(Math.abs(afterScreen.y - cursor.y) < 0.001);
matrix로 보면 anchor 보정이다
cursor anchored zoom은 matrix 관점에서 보면 “screen anchor 주변으로 camera inverse를 다시 맞추는 일”입니다.
screen = cameraToScreen * world
world = screenToWorld * screen
줌 전후에 아래 식이 유지되어야 합니다.
screenToWorld(nextCamera) * cursorScreen
===
screenToWorld(oldCamera) * cursorScreen
그래서 새 camera 위치를 anchorWorld - cursorScreen / nextZoom으로 다시 계산합니다. 이 설명이 잡히면 WebGL에서 projection/camera matrix를 uniform으로 보낼 때도 같은 사고를 유지할 수 있습니다.
wheel delta는 정규화가 필요하다
브라우저와 장치마다 wheel delta는 다르게 들어올 수 있습니다. 트랙패드와 마우스 휠도 느낌이 다릅니다.
처음에는 간단히 시작합니다.
const zoomFactor = Math.exp(-event.deltaY * 0.001);
브라우저 event까지 묶으면 이런 형태가 됩니다.
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const cursor = clientToCanvas(event, canvas);
const zoomFactor = Math.exp(-event.deltaY * 0.001);
editor.dispatch({
type: "setCamera",
camera: zoomAt(editor.state.camera, cursor, zoomFactor)
});
}, { passive: false });
나중에는 min/max zoom, smooth zoom, pinch gesture를 추가할 수 있습니다. 하지만 핵심은 항상 같습니다. 커서 아래 world point를 고정합니다.
오늘의 핵심
zoom은 scale 값 하나가 아니라 camera를 다시 계산하는 일입니다.
1. cursor screen point를 world로 변환한다.
2. zoom을 바꾼다.
3. 같은 world point가 같은 screen point에 오도록 camera를 조정한다.
이 감각이 있어야 Figma처럼 자연스러운 캔버스 조작을 만들 수 있습니다.