본문 바로가기

자바스크립트

JS) 무한 캔버스(Infinity Canvas)의 원리와 구현

들어가며...

디자인에 관련된 일에 종사하는 사람이라면 "피그마" 앱을 익히 들었을 것입니다.

디자이너 뿐만이 아니라 프론트엔드, 백엔드 등의 기타 개발자, 혹은 피그마 앱을 사용하는 팀 또는 회사에서 이를 사용하거나 본 적이 있는 사람이 대부분일 것입니다.

 

피그마는 매우 유용한 툴입니다. "dev"모드라는 유로 구독을 하면 디자이너가 원하는 부분의 색상이나 디자인을 그대로 css 코드로 변환해줍니다. (어도비는 피그마를 무려 28조원에 인수했습니다.)

 

저희 또한 피그마 같은 회사를 창업해 28조원을 벌어보려합니다.

따라서 우리 팀은 피그마 앱의 카피앱을 만드려합니다.

앱의 가장 중요한 부분은 무엇이 있을까요?

 

저는 딱 두개를 뽑았습니다.

webGL을 이용한 GPU의 사용과 무한 캔버스의 구현입니다.

하지만 자바스크립트에서 GPU를 사용하기엔 제약이 있습니다. "transform"과 같은 특정 css 속성을 사용해야하고, 

캔버스 내에서 GPU를 사용하려면 "WebAssembly"를 사용해야 하기에 저는 CPU를 사용하는 getContext('2d')로 무한 캔버스를 구현하려합니다.

 

Figma

 


 

Infinity Canvas

인피니티 캔버스의 원리는 매우 간단합니다.

 

(출처)

 

위 그림처럼 끝없이 펼쳐진 캔버스를 그리고 특정 영역만을 브라우저에 렌더링하는 것입니다.

네, 맞습니다. 이 원리는 "캐로셀 이벤트"과 같은 원리로 동작합니다.

하지만 이는 원리일 뿐, 저희는 캐로셀과 같이 캔버스의 크기를 window.innerWidth * 1e9으로 직접적으로 나타내지 않습니다.

제아무리 큰 수라고 해도, 언젠간 끝을 만날테니 이는 저희가 원하는 진정한 "INFINITY"가 아닙니다.

 

코드 작성에 들어가기에 앞서 기억하세요.

캔버스의 크기는 언제나 window와 같습니다.

 


이제 만들어봅시다.

 

우리는 html파일과 css파일, 그리고 Javascript파일이 필요합니다.

 

// html

<canvas id="canvas" />

 

우리는 먼저 캔버스를 하나 생성할 겁니다. 그리고 이에 스타일을 지정할 것입니다.

 

      #canvas {
        position: fixed;
        top: 0;
        left: 0;
        width: 100vw;
        height: 100vh;
        overflow: hidden;
      }

 

또한 자바스크립트에서 이를 가져올 것입니다.

 

// js

document.addEventListener("DOMContentLoaded", (event) => {
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
}

 

모든 준비가 끝났습니다.

 

이제 인피니티 캔버스의 동작 영역을 두 분류로 나눌 것입니다.

 

  1. 인피니티 캔버스의 이동과 줌인/줌아웃
  2. 캔버스를 렌더링하는 부분

우선 이동과 줌인/줌아웃을 구현해보겠습니다.

우리는 코드에 변수 몇개를 더 선언해주어야 합니다.

 

    const maxZoom = 5;
    const minZoom = 0.05;
    const zoomSensitivity = 0.005;
    const cameraOffset = {
      x: window.innerWidth / 2,
      y: window.innerHeight / 2,
    };
    let cameraZoom = 1; //init camera zoom
    let reqId = null;
    let isDragging = false; // is inf canvas drag and drop
    let lastMousePos = { x: 0, y: 0 };

 

maxZoom: 줌아웃 시의 최대치입니다.

zoomSensitivity: 스크롤 한 번에 얼마만큼의 줌을 하느냐 결정하는 변수입니다.

cameraOffset: 현재 캔버스 위치의 중앙 값입니다.

cameraZoom: 최초 줌 값입니다.

reqId: requestAnimationFrame을 저장하기 위한 값입니다.(필수는 아닙니다.) 

isDragging: 사용자가 드래그를 이용하여 캔버스를 이동시키는 중인지 확인합니다.

lastMousePos: 드래그 시, 이전 마우스 포인트 위치를 저장하기 위함입니다.

 

 

또한 우리는 줌인/아웃, 캔버스의 이동을 위해 이벤트를 등록할 것입니다.

 

    canvas.addEventListener("wheel", onZoom);
    canvas.addEventListener("mousedown", onMouseDown);
    canvas.addEventListener("mousemove", onMouseMove);
    canvas.addEventListener("mouseup", onMouseUp);

 


zoomIn/zoomOut

 

우선 줌인/줌아웃부터 구현해보도록 하겠습니다.

저희는 사용자가 원하는 양의 줌인/줌아웃을 구현하기 위해 "wheel"이벤트로부터 사용자가 휠을 얼마나 굴렸는지 확인해야합니다.

그 값은 event.deltaY 값입니다.

 

    const onZoom = (e) => {
      e.preventDefault();
      // 줌인/아웃
      adjustZoom(-e.deltaY * zoomSensitivity, e.clientX, e.clientY);
    };
    
     const adjustZoom = (zoomAmount, mouseX, mouseY) => {
     }

 

여기서 우리는 스크롤 양을 확인하는 event.deltaY값의 부호를 바꿔주었습니다. 

왜냐하면 휠 업시에는 줌인을, 휠 다운 시에는 줌 아웃을 할 예정이기 때문입니다.

또한 저희가 설정한 zoomSensitivity값을 곱해주며 저희가 설정한 줌 양을 결정합니다.

 

"adjustZoom"함수는 본격적인 줌인/줌아웃 로직입니다.

우선 사용자가 줌을 얼마나 했는지 확인하고, 새 줌을 만들어야합니다.

하지만 그 값은 minZoom <= newZoom <= maxZoom입니다.

const newZoom = Math.min(
        maxZoom,
        Math.max(minZoom, cameraZoom + zoomAmount)
      );

 

다음으로 우리는 새 줌과 기존 줌의 비율을 계산할 것입니다. 이 비율은 마우스 커서를 중심으로 줌인/줌아웃하는 로직에 사용됩니다.

 

또한 우리는 사용자의 마우스 커서 위치를 계산하고 이를 캔버스 좌표로 변환할 것입니다.

 

전체 "adjustZoom"함수는 다음과 같습니다.

    const adjustZoom = (zoomAmount, mouseX, mouseY) => {
      // 줌 계산
      const newZoom = Math.min(
        maxZoom,
        Math.max(minZoom, cameraZoom + zoomAmount)
      );
      const zoomFactor = newZoom / cameraZoom;

      const canvasRect = canvas.getBoundingClientRect();
      const offsetX =
        (mouseX - canvasRect.left - canvas.width / 2) / cameraZoom;
      const offsetY =
        (mouseY - canvasRect.top - canvas.height / 2) / cameraZoom;

      cameraOffset.x -= offsetX * (zoomFactor - 1);
      cameraOffset.y -= offsetY * (zoomFactor - 1);

      cameraZoom = newZoom;
    };

 


캔버스의 이동

캔버스의 이동은 마우스를 클릭한 후 드래그하여 이동합니다.

 

    const onMouseDown = (e) => {
      // 무한 캔버스 드래그 앤 드랍
      isDragging = true;
      lastMousePos.x = e.clientX;
      lastMousePos.y = e.clientY;
    };

 

onMouseDown 함수는 마우스 좌표를 저장하고, 드래깅 중인지를 확인하는 boolean값을 정합니다.

 

    const onMouseMove = (e) => {
      // 무한 캔버스 드래그 앤 드랍
      if (isDragging) {
        const dx = e.clientX - lastMousePos.x;
        const dy = e.clientY - lastMousePos.y;
        cameraOffset.x += dx / cameraZoom;
        cameraOffset.y += dy / cameraZoom;
        lastMousePos.x = e.clientX;
        lastMousePos.y = e.clientY;
      }
    };

 

마우스 이동은 현재 마우스 좌표와 이전 마우스 좌표를 계산하여 이동거리를 구합니다.(dx, dy)

위 값을 카메라가 이동해야하는 거리고 변환합니다.

 

"onmouseup"은 매우 간단합니다.

 

    const onMouseUp = () => {
      // 드래그 앤 드랍 end
      isDragging = false;
    };

 

이로써 줌인/줌아웃과 카메라의 이동을 모두 마쳤습니다.

 


drawing on canvas

 

"그러나 아직 아무것도 되질 않는습니다."

 

당연합니다. 저희는 캔버스에 아무것도 그리지 않았고, 카메라와 줌은 실제로 이동하지만 이를 반영하지는 않았습니다.

 

코드 맨 하단에 다음 코드를 추가합니다.

 

drawStart();

 

drawStart함수는 캔버스에 무언가를 그리는 함수입니다.

무언가를 그릴지는 자유입니다.

하지만 drawStart함수는 계속해서 그려야합니다. 즉, 카메라의 이동, 줌인/줌아웃의 계산 결과를 어딘가에 저장해 둔 후, 그 계산 결과로 캔버스를 계속해서 업데이트합니다.

 

다음은 drawStart함수입니다.

 

const drawStart = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;

      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      ctx.translate(canvas.width / 2, canvas.height / 2);
      ctx.scale(cameraZoom, cameraZoom);
      ctx.translate(
        -canvas.width / 2 + cameraOffset.x,
        -canvas.height / 2 + cameraOffset.y
      );
      ctx.fillStyle = "red";
      
      ctx.fillRect(-250, -250, 500, 500);
      ctx.restore();
      reqId = requestAnimationFrame(drawStart);
    };

 

코드를 차근차근 뜯어봅시다.

우리는 우선 캔버스를 업데이트 하기 위해 현재 캔버스 내 모든 것을 지워야합니다.

그 다음 중앙 값을 맞추고(ctx.translate(canvas.width /2 , ...)

ctx.scale()메서드로 사용자의 줌 양 만큼 확대/축소합니다.

마지막으로 중요한 것이 있습니다.

 

사용자가 캔버스를 이동시켰다하면, 그만큼 x,y값의 좌표도 변화시켜주어야합니다. 하지만, 모든 그림,도형의 좌표값을 변경시키기에는 도형의 수가 늘어갈 수록 연산 또한 기하급수적으로 증가합니다.

따라서 ctx.translate() 메서드로 손쉽게 캔버스 "자체"를 옮길 수 있습니다. 

한 번 해보세요. 

ctx.fillRect(0,0,100,100)

위 코드는 0부터 가로세로 100인 사각형을 그립니다. 0,0이 맨 왼쪽 위 좌표값입니다.

drawStart함수에 위 코드를 추가해보세요.

0,0부터 시작한 사각형의 위치가 캔버스 위에서 바뀌나요?

"아닙니다."

실제 0,0좌표값은 아니지만 저희가 cameraOffset.x와 y값만큼 translate해주었기 때문에 0,0좌표값과 다름없습니다.


이것이 끝입니다.

 

하지만 이것 만으로 피그마 앱을 만들 수는 없습니다.

피그마 앱에는 매우 많은 타입의 도형과 그 children요소 또한 매우 많이 존재하기에 단순한 getContext('2d')만으로 구현할 수 없습니다.

더 정확하게는 구현은 가능하지만 버벅임이 발생할것입니다.

 

따라서 피그마는 GPU를 사용할 수 있는 C++ 언어로 작성되었습니다.

 

전체 코드는 다음과 같습니다.


  document.addEventListener("DOMContentLoaded", (event) => {
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    const maxZoom = 5;
    const minZoom = 0.05;
    const zoomSensitivity = 0.005;
    const cameraOffset = {
      x: window.innerWidth / 2,
      y: window.innerHeight / 2,
    };
    let cameraZoom = 1; // init camera zoom
    let reqId = null;
    let isDragging = false; // is inf canvas drag and drop
    let lastMousePos = { x: 0, y: 0 };

    const drawStart = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;

      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      ctx.translate(canvas.width / 2, canvas.height / 2);
      ctx.scale(cameraZoom, cameraZoom);
      ctx.translate(
        -canvas.width / 2 + cameraOffset.x,
        -canvas.height / 2 + cameraOffset.y
      );
      ctx.fillStyle = "red";
      ctx.fillRect(0,0,100,100);
      ctx.restore();
      reqId = requestAnimationFrame(drawStart);
    }

    const onZoom = (e) => {
      e.preventDefault();
      adjustZoom(-e.deltaY * zoomSensitivity, e.clientX, e.clientY);
    };

    const adjustZoom = (zoomAmount, mouseX, mouseY) => {
      const newZoom = Math.min(
        maxZoom,
        Math.max(minZoom, cameraZoom + zoomAmount)
      );
      const zoomFactor = newZoom / cameraZoom;

      const canvasRect = canvas.getBoundingClientRect();
      const offsetX =
        (mouseX - canvasRect.left - canvas.width / 2) / cameraZoom;
      const offsetY =
        (mouseY - canvasRect.top - canvas.height / 2) / cameraZoom;

      cameraOffset.x -= offsetX * (zoomFactor - 1);
      cameraOffset.y -= offsetY * (zoomFactor - 1);

      cameraZoom = newZoom;
    };

    const onMouseDown = (e) => {
      isDragging = true;
      lastMousePos.x = e.clientX;
      lastMousePos.y = e.clientY;
    };

    const onMouseMove = (e) => {
      if (isDragging) {
        const dx = e.clientX - lastMousePos.x;
        const dy = e.clientY - lastMousePos.y;
        cameraOffset.x += dx / cameraZoom;
        cameraOffset.y += dy / cameraZoom;
        lastMousePos.x = e.clientX;
        lastMousePos.y = e.clientY;
      }
    };

    const onMouseUp = () => {
      isDragging = false;
    };

    canvas.addEventListener("wheel", onZoom);
    canvas.addEventListener("mousedown", onMouseDown);
    canvas.addEventListener("mousemove", onMouseMove);
    canvas.addEventListener("mouseup", onMouseUp);
    drawStart();
  });

감사합니다.

 

아래는 바닐라 자바스크립트가 아닌 리액트로 작성된 무한 캔버스 코드입니다. 리액트 유저들을 위해 작성했습니다.

리액트에서는 피그앱과 같이 컨트롤 + 휠업/다운으로 줌인/줌아웃을 구현했습니다. 

const maxZoom = 5;
const minZoom = 0.01;
const scrollSensitivity = 5;
const zoomSensitivity = 0.001;

const Canvas = ({ data }: any) => {
  const ref = useRef<null | HTMLCanvasElement>(null);
  const ctxRef = useRef<null | CanvasRenderingContext2D>(null);
  const cameraOffsetRef = useRef({
    x: window.innerWidth / 2,
    y: window.innerHeight / 2,
  });
  const cameraZoomRef = useRef<number>(0.1);
  //   const scroll_sensitivityRef = useRef<number>(0.0005);
  const isDraggingRef = useRef<boolean>(false);
  const lastZoomRef = useRef<number>(cameraZoomRef.current);

  const drawFigure = () => {
    // draw something...
  };

  const drawStart = () => {
    if (!ref.current || !ctxRef.current) return;
    ref.current.width = window.innerWidth;
    ref.current.height = window.innerHeight;

    ctxRef.current.translate(window.innerWidth / 2, window.innerHeight / 2);
    ctxRef.current.scale(cameraZoomRef.current, cameraZoomRef.current);
    ctxRef.current.translate(
      -window.innerWidth / 2 + cameraOffsetRef.current.x,
      -window.innerHeight / 2 + cameraOffsetRef.current.y
    );

    ctxRef.current.clearRect(0, 0, window.innerWidth, window.innerHeight);
    drawFigure();
    requestAnimationFrame(drawStart);
  };
  const onScreenMove = (e: any) => {
    e.preventDefault();
    if (e.ctrlKey) {
      adjustZoom(-e.deltaY * zoomSensitivity, null);
    }
    cameraOffsetRef.current = {
      x: cameraOffsetRef.current.x - e.deltaX * scrollSensitivity,
      y: cameraOffsetRef.current.y - e.deltaY * scrollSensitivity,
    };

    // drawStart();
  };

  const adjustZoom = (zoomAmount: any, zoomFactor: any) => {
    if (!isDraggingRef.current) {
      if (zoomAmount) {
        cameraZoomRef.current += zoomAmount;
      } else if (zoomFactor) {
        console.log(zoomFactor);
        cameraZoomRef.current = zoomFactor * lastZoomRef.current;
      }

      cameraZoomRef.current = Math.min(cameraZoomRef.current, maxZoom);
      cameraZoomRef.current = Math.max(cameraZoomRef.current, minZoom);
    }
  };

  useEffect(() => {
    if (!ref.current) return;
    const ctx = ref.current.getContext("2d");
    if (!ctx) return;
    ctxRef.current = ctx;
    console.log(window.navigator.platform);
    ctx.fillStyle = "red";
    ctx.fillRect(-500, -500, 500, 500);
    drawStart();

    ref.current.addEventListener("wheel", onScreenMove);
  }, []);

  return (
    <canvas
      ref={ref}
      style={{
        background: "yellowgreen",
        width: "100dvw",
        height: "100dvh",
        overflow: "hidden",
        position: "fixed",
      }}
    />
  );
};

export default Canvas;

 

'자바스크립트' 카테고리의 다른 글

THREEJS로 인테리어 기능  (1) 2024.09.23
threejs) 프레임마다 업데이트되는 TextGeometry  (2) 2024.08.25
React) mediasoup을 이용한 SFU  (1) 2024.05.03
JS) 논리적 AND OR  (0) 2024.04.16
JS) remove() 와 removeChild()  (0) 2024.04.12