[HTML5 Canvas] 글자 적는 애니메이션 구현하기

Yoonlang·2022년 8월 11일
0
post-thumbnail

오늘은 Canvas를 통해 썸네일과 같이 그려보고자 한다.
태그에 React가 들어가 있는데, Next.js 프레임워크 환경에서 canvas 작업을 했기 때문에 태그에 넣었다. 따라서 React + Typescript 환경에서는 canvas를 어떻게 작업하는지도 함께 알아보자.

작성자의 Typescript 실력이 미흡하여 Typescript 관련 내용은 틀릴 수도 있습니다.

목차

  1. React에서 Canvas 작업하기
  2. 선 움직이기
  3. 그려지는 선 구현 함수 짜기
  4. 그려지는 원 구현 함수 짜기
  5. 썸네일 구현 과정에서의 에러 및 해결

React에서 Canvas 작업하기

컴포넌트를 생성하고, canvas를 반환한다.

const Canvas = () => {
	const canvas = useRef<HTMLCanvasElement>(null);

	useEffect(() => {
    	if(!canvas) return;
        
        ...
        
	}, [])

	return (
    <>
      <div>
        <canvas ref={canvas}></canvas>
      </div>
      <style jsx>{`
        canvas {
          width: 500px;
          height: 500px;
        }
      `}</style>
    </>
  );
}

export default Canvas;

이 때, canvas에 ref 설정해주고, useEffect 내에서 작업을 진행한다.
이제 작업을 시작해 보자.

선 움직이기

선, 원 그리기는 쉬우니까 바로 움직이기로 들어가 보자.
Javascript에는 초당 60번 업데이트되는 requestAnimationFrame 함수가 존재한다. 우리는 이를 이용하여 움직이는 모션을 구현할 것이다.

...
	useEffect(() => {
    	if(!canvas) return;  // Typescript
        const c: HTMLCanvasElement = canvas.current;
        const ctx = c.getContext('2d');
        if (!ctx) return;
        canvas.width = 700;
        canvas.height = 700;
        
        ctx.strokeStyle = '#000';
        
        let now = 0;
        const update = () => {
        
        	// canvas 초기화
        	ctx.clearRect(0, 0, 700, 700);
			
            // 선 그리기
            ctx.beginPath();
            ctx.moveTo(10, now);
            ctx.lineTo(10, now + 2);
            ctx.stroke();
           
	        const animation = requestAnimationFrame(update);        
            now++;
            if(now >= 60) cancelAnimationFrame(animation);
            // 초당 60번이기 때문에 1초 뒤엔 애니메이션 업데이트 종료
        }
    }, [])

...

사실 움직이기라고 해서, 선 그리기와 크게 다를 게 없다. 결국 그리는 건 마찬가지지만 값 변동을 이용해서 조금씩 위치를 수정시켜주는 것이 이동이다.

그려지는 선 구현 함수 짜기

이제 함수를 짜보자. 내가 원하는 함수는 다음과 같다.
두 점의 좌표, 굵기, 그려지는 시간을 인자로 전달하면 첫 좌표에서부터 두 번째 좌표까지 시간에 맞춰 그려지는 것이다. 굵기도 줄 수 있으니 사실 선, 사각형 다 이 함수를 통해 구현 가능하다.

시간에 대해서는 인자로는 1초당 1000 단위로 받기로 하고, 아까 위에서 봤던 now 변수가 1초에 60씩 올라가니 이것을 통해 카운팅 한다.

어떻게 그려지게 할 것인가?

내 계획은 다음과 같다.
시작 점과 끝 점 사이를 조각낸다. 기준은 now이다.
만약 1초 동안 진행이라면 함수가 60번 실행되므로 60조각이라 할 수 있다.
그 후, 각 함수가 실행될 때마다 그려지는 선의 길이가 늘어난다. 이는 수학에서 영감을 받았다.

선의 내분점 공식을 이용하여 원하는 시간에 맞게 속도를 맞출 수 있다.

이제 실제 함수를 구현해 보자.

interface drawLineFunc {
  (
    ctx: CanvasRenderingContext2D | null,
    x1: number,
    y1: number,
    x2: number,
    y2: number,
    bold: number,
    time: number,
    startTime: number,
    now: number
  ): void;
}

const drawLine: drawLineFunc = (ctx, x1, y1, x2, y2, bold, time, startTime, now) => {
	if (!ctx) return;
    const pieces = (time / 1000) * 60;
    const speed = now - (startTime / 1000) * 60;
    if (now / 60 < startTime / 1000) return;
    if (now - (startTime / 1000) * 60 >= pieces) return;
    
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = bold;
    ctx.beginPath();
    ctx.moveTo(
      x1 * ((pieces - speed) / pieces) + x2 * (speed / pieces),
      y1 * ((pieces - speed) / pieces) + y2 * (speed / pieces)
    );
    ctx.lineTo(
      x1 * ((pieces - speed + 1.5) / pieces) + x2 * ((speed - 1.5) / pieces),
      y1 * ((pieces - speed + 1.5) / pieces) + y2 * ((speed - 1.5) / pieces)
    );
    ctx.stroke();
  };


사실 이 함수엔 큰 문제가 있다.
원하는 시간을 극단적으로 줄여버리면 ctx.lineTo의 상숫값의 영향이 커져버려 원하는 그림이 안 나올 수도 있다.

그려지는 원 구현 함수 짜기

원을 그리는 함수는 더욱 쉽다. 바로 코드로 보자.

interface drawCircleFunc {
  (
    ctx: CanvasRenderingContext2D | null,
    x: number,
    y: number,
    radius: number,
    bold: number,
    start: number,
    end: number,
    time: number,
    startTime: number,
    now: number,
    reverse?: boolean
  ): void;
}

const drawCircle: drawCircleFunc = (ctx, x, y, radius, bold, start, end, time, startTime, now, reverse = false) => {
    if (!ctx) return;
    const pieces = (time / 1000) * 60;
    const speed = now - (startTime / 1000) * 60;
    if (now / 60 < startTime / 1000) return;
    if (now - (startTime / 1000) * 60 >= pieces) return;
    
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = bold;
    ctx.beginPath();
    ctx.arc(
      x,
      y,
      radius,
      Math.PI * start,
      Math.PI * ((start * (pieces - speed)) / pieces + (end * speed) / pieces),
      reverse
    );
    ctx.stroke();
  };

reverse 변수는 원이 돌아가는 방향을 위해서이다. ctx.arc()에선 false가 기본값이다.

이 함수 또한 time 값이 작으면 원하는 원 모양이 나오지 않을 수도 있다.

썸네일 구현 과정에서의 에러 및 해결

time 값에 따른 오차 발생 문제
위의 함수 내용에서 말했듯, time 값에 따라 완전히 이상적인 모양이 나오지 않을 수 있다. 실제로 내가 썸네일을 구현할 때도 그랬고, 그것은 직접 오차를 줄여가며 진행했다.

선의 우선순위?
126에서 1을 보면 꼭다리가 항상 일직선 기둥보다 앞에 보여야 한다. 이를 위해 어떻 게할지 고민했는데, 결국 canvas를 여러 개 사용하고, 이를 겹치기로 결정했다.

그라데이션
그라데이션용 그리기 함수를 구현했었다. rgb(0, 0, 0)을 통해 값이 바뀌면 색상이 바뀌는 형태로 만들었는데, time의 영향을 너무 많이 받아 부자연스러운 그라데이션이 완성되었다. 이에 대한 해결책으로 ctx.createLinearGradient()를 사용하여 그라데이션 사각형을 생성 후, 이를 가로막은 canvas를 시간에 맞게 지워주는 형태로 만들었다.

이를 위해 총 canvas가 3개 필요했다. 그라데이션이 제일 밑에 깔리고, 그라데이션을 가려줄 커튼 canvas, 마지막으로 다른 흰색 선들이 들어가는 canvas이다.

전체 코드

읽어주셔서 감사합니다.

0개의 댓글