DOM event target 없이 hit testing 하기
CSS 기반 에디터에서는 사용자가 도형을 클릭했을 때 브라우저가 많은 일을 대신합니다.
canvas.addEventListener("pointerdown", (event) => {
console.log(event.target);
});
도형이 DOM 요소라면 event.target에서 어떤 요소를 눌렀는지 바로 출발할 수 있습니다. 하지만 Canvas/WebGL/WebGPU로 넘어가면 Canvas 안쪽에는 도형 DOM이 없습니다. 이벤트 target은 보통 <canvas> 하나입니다.
그래서 질문이 바뀝니다.
브라우저가 알려준 target은 canvas다.
그럼 canvas 안의 어떤 scene node를 누른 걸까?
hit testing은 model geometry를 검사한다
GPU 렌더러는 픽셀을 그리지만, 편집기는 도형을 선택해야 합니다. 그래서 hit testing은 scene model을 대상으로 합니다.
function hitTest(scene, worldPoint) {
for (const node of [...scene.nodes].reverse()) {
if (pointInNode(worldPoint, node)) return node;
}
return null;
}
여기서 뒤에서부터 검사하는 이유는 보통 나중에 그린 도형이 더 앞에 있기 때문입니다. CSS의 z-index처럼 브라우저가 대신 정리해주지 않으니, scene model의 layer order를 기준으로 직접 판단합니다.
포인터는 먼저 world 좌표로 바꾼다
pointer event가 주는 좌표는 screen/client 좌표입니다. 하지만 도형은 보통 world 좌표에 저장합니다. 따라서 hit test 전에 좌표계를 맞춰야 합니다.
client -> canvas local -> world
camera가 없다면 local 좌표만으로도 충분합니다. 하지만 infinite canvas에서는 pan과 zoom이 있기 때문에 screenToWorld 변환이 필요합니다.
const local = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
const world = {
x: local.x / camera.zoom + camera.x,
y: local.y / camera.zoom + camera.y
};
broad phase와 narrow phase
처음에는 모든 node를 순회해도 괜찮습니다. 하지만 도형이 많아지면 두 단계로 나눕니다.
broad phase: 빠른 bounds 검사
narrow phase: 정확한 도형 내부 검사
사각형만 있다면 bounds 검사 자체가 hit test입니다. 회전된 사각형, path, text, stroke까지 들어가면 정확한 검사가 따로 필요합니다. 이때 자주 쓰는 방법은 포인터를 node local 좌표로 되돌리는 것입니다.
world point -> inverse node matrix -> local point
local point가 node의 0..width, 0..height 안에 있는가?
이 방식은 CSS matrix 강의에서 배운 inverse matrix가 GPU 에디터에서도 그대로 살아남는 지점입니다.
오늘의 핵심
Canvas 안에는 DOM target이 없습니다. 대신 scene model이 있습니다.
pointer event
-> canvas local
-> world point
-> scene model hit test
-> selected node
이 흐름이 잡히면 WebGL/WebGPU renderer를 붙여도 선택 도구의 중심은 흔들리지 않습니다. 렌더러는 보이는 픽셀을 만들고, editor core는 의미 있는 도형을 찾습니다.