리액트에서 canvas태그 이용하여 2d 애니메이션 만들기

이수연·2023년 6월 5일
1

여러 웹사이트를 탐방하다가, 누데이크란 디저트 브랜드의 홈페이지가 인상적이어서 방법을 알아보았다. 이 사이트는 canvas 태그를 이용했다는것을 알게 되었고, canvas태그를 공부하는겸 포트폴리오에 적용해 보았다.

본문에 들어가기 앞서 canvas 태그란 무엇일까요?

  • Canvas는 웹상에 도형과 같은 그래픽적인 것을 표현 할 때 사용하는 HTML 태그 입니다. 즉, 스크립트를 사용하여 그래프나 도형, 이미지 등을 그릴 수 있는 html5의 요소이다.

1. 반응형에 맞춰서 canvas 태그 리사이징하기

먼저 모든 작업에 들어가기 앞서, canvas 태그가 모든 화면 비율에 맞춰서 줄어들수 있도록 canvas 태그 리사이징 작업을 진행 해야됩니다.

아래 코드를 예시로 설명 드리겠습니다.

  const canvasRef = useRef(null)

  useEffect(() => {
    const canvas = canvasRef.current
    const canvasParent = canvas.parentNode
    const ctx = canvas.getContext('2d')

    let canvasWidth, canvasHeight

    function resize() {
      canvasWidth = canvasParent.clientWidth
      canvasHeight = canvasParent.clientHeight
      canvas.style.width = canvasWidth + 'px'
      canvas.style.height = canvasHeight + 'px'
      canvas.width = canvasWidth
      canvas.height = canvasHeight
    }

    window.addEventListener('resize', resize)
    resize()

    return () => {
      window.removeEventListener('resize', resize)
    }
  }, [])

  return (
    <div className='nudake'>
      <canvas ref={canvasRef} />
    </div>
  )

먼저 자바스크립트에서는 document querySelector로 캔버스 태그의 요소를 가져오지만, 리액트에서는 useRef 훅을 이용하여 돔요소에서 접근할수 있습니다. 따라서 useRef로 캔버스 요소를 가져오고, resize하는 함수를 정의 합니다. 여기서 캔버스의 실제 width와 height값(canvas.width or canvas.height) 화면에 보여지는 값 canvas.style을 정의 해줘야됩니다. 화면에 보여지는 canvas.style은 부모요소의 값에 영향을 받기 때문입니다.

2. mouse 이벤트를 이용하여 이미지 지워주는 로직 만들기

두번째로는 이미지에 mouse down이벤트 했을때, 투명한 도형을 만들며 다음 이미지가 보이게끔 하는 로직을 구현 하겠습니다.

  const canvasRef = useRef(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const canvasParent = canvas.parentNode;
    const ctx = canvas.getContext("2d");

    const imageSrcs = [image1, image2, image3];
    let currIndex = 0;
    let prevPos = { x: 0, y: 0 };

    let canvasWidth, canvasHeight;

    function resize() {
      canvasWidth = canvasParent.clientWidth;
      canvasHeight = canvasParent.clientHeight;
      canvas.style.width = canvasWidth + "px";
      canvas.style.height = canvasHeight + "px";
      canvas.width = canvasWidth;
      canvas.height = canvasHeight;

      drawImage();
    }

    function drawImage() {
      ctx.clearRect(0, 0, canvasWidth, canvasHeight);
      const image = new Image();
      image.src = imageSrcs[currIndex];
      // 이미지를 비동기적으로 불러오기때문에 image onload메서드를 써야됨
      image.onload = () => {
        ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight);
      };
    }

    function onMousedown(e) {
      canvas.addEventListener("mouseup", onMouseUp);
      canvas.addEventListener("mouseleave", onMouseUp);
      canvas.addEventListener("mousemove", onMouseMove);
      prevPos = { x: e.offsetX, y: e.offsetY };
      // 처음 mousedown했을때 원의 좌표
    }

    function onMouseUp() {
      canvas.removeEventListener("mouseup", onMouseUp);
      canvas.removeEventListener("mouseleave", onMouseUp);
      canvas.removeEventListener("mousemove", onMouseMove);
    }

    function onMouseMove(e) {
      drawCircles(e);
    }

    function drawCircles(e) {
      const nextPos = { x: e.offsetX, y: e.offsetY };
      //  mousedown하고 이동했을때의 원의 좌표
      const dist = getDistance(prevPos, nextPos);
      const angle = getAngle(prevPos, nextPos);
      // xi 와 xi+1의 각도
      for (let i = 0; i < dist; i++) {
        const x = prevPos.x + Math.cos(angle) * i;
        const y = prevPos.y + Math.sin(angle) * i;

        ctx.globalCompositeOperation = "destination-out";
        // destination-out 합성 방식을 사용하면 ctx.arc()와 같은 메서드로 그리는 도형이 이미 그려진 도형과 겹치는 부분을 제거할 수 있습니다.
        // 즉, 현재 도형을 사용하여 기존에 그려진 도형이나 배경을 투명하게 만듭니다.
        ctx.beginPath();
        ctx.arc(x, y, 50, 0, Math.PI * 2);
        // (x, y, radius, startAngle, endAngle, anticlockwise)
        ctx.fill();
        ctx.closePath();
      }
      // 포문으로 빈틈을 채워준다

      prevPos = nextPos;
      // 다음턴엔 prevpos가 nextpos가 되니까 이렇게 할당해줌
    }

    canvas.addEventListener("mousedown", onMousedown);
    window.addEventListener("resize", resize);
    resize();

    return () => {
      canvas.removeEventListener("mousedown", onMousedown);
      window.removeEventListener("resize", resize);
    };
  }, []);

  return (
    <div className="nudake">
      <canvas ref={canvasRef} />
    </div>
  );

위의 예시코드를 살펴보자면 순서는 아래와 같습니다.
1. import한 이미지를 배열에 담아줍니다.
2. drawImage함수를 만들고, ctx.clearRect 메서드를 활용하여 도형의 값을 초기화 해줍니다.
3. 이미지를 불러오는데 시간이 걸리기 때문에 image.onload 메서드를 이용하여 호출하고 재귀함수로 계속 호출을 해줍니다.
4. onMousedown 함수를 정의하고, mousedown 이벤트를 실행 할수 있게 합니다. 이때 mousemove이벤트와 mouseup 이벤트도 함께 호출하여 이벤트가 사이드 이펙트 없이 제대로 실행 될수 있게끔 합니다.
5. 여기서 중요한점은 onMouseUp 함수에서 마우스를 뗏을때 모든 이벤트가 호출되지 않도록 설정을 해줘야 됩니다. (성능상 문제 발생)
6. 다음은 mousedown 이벤트를 실행 했을때 투명한 도형을 만들어 줘야됩니다. drawCircles함수내에서 수행됨// 이 로직은 복잡하기때문에 아래 예시를 말씀드리면서 정리하겠습니다.

위의 사진과 같이 x1, x2, x3, x4의 점이 있습니다. mouse down 이벤트를 세팅할때, 빠르게 선을 긋게 되면 위의 사진과 같이 점사이에 공백이 생깁니다. 이를 방지하기위해 공백사이를 선으로 메꾸어 주어야 됩니다.
x2의 좌표값은 삼각함수의 공식을 이용하면 되는데,
x2.x = x1.x+x1.x cosθ가 되고, x2.y =x1.x +x1.ysinθ가 됩니다. 이런식으로 x3, x4를 구하면 총 공식은 아래와 같습니다.
x(i).x = x1.x + x1cosθ x i
x(i).y = y1.y +y1
sineθ x i

이렇게 나온 값을 이용하여 피타고라스의 정의로 빗변의 길이를 구합니다.
그이후 따로 함수로 빼서 값을 계산한한후 화면에 mouse down 이벤트할때, 빗변의 길이도 같이 구하게끔 해줍니다. 아래는 두점의 거리와, 두점의 각도를 구하는 함수입니다.

export function getDistance(p1, p2) {
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;

  return Math.sqrt(dx * dx + dy * dy);
}

export function getAngle(p1, p2) {
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;

  return Math.atan2(dy, dx);
  // 두점사이의 각도를 구하는 공식
}

이렇게 세팅을 한후 drawCircles 함수내부에서 값을 구합니다. prevPos는 이전 x,y의 값 nextpos는 다음점의 x,y값이고 for문을 이용하여 두점 사이의 빈공간에도 원이 그려 지도록 합니다. 여기서 다음 이미지가 보이도록 하게끔 하기 위해선 globalCompositeOperation 메서드를 이용하여 속성을 destination-out로 주면 됩니다. (이속성은 화면에 그린원을 투명하게끔 함)

0개의 댓글