리액트로 애니메이션 띄우기 (feat. Canvas & RequestAnimationFrame)

글을 시작하며

😝 글 작성 동기

좋은 기회로(?) 리액트로 캔버스 애니메이션을 구현해보았는데
조금 마이너한 기술이라고 생각되어 정리를 망설였지만
게더타운 등의 서비스를 보면
캔버스 애니메이션 사용법을 정리해두고 공유하는게 좋겠다고 생각하여 글을 작성하게 되었습니다.

😝 미리 보는 결과물


키보드 입력값에 따라 요리조리 움직이고 모습도 변화하는 귀여운 마자용😝에 대한 예제입니다.
😝 배포 링크 😝
😝 깃허브 링크 😝

😝 주의 사항

리액트에서 정말 중요한 상태관리, 컴포넌트 분해 등의 개념보다
requestAnimationFrame으로 화면을 리페인트하는 로직을 더 중시하는 예제입니다!
예제에서 많이 사용된 useRef의 사용은 지양되고 있으니
재미로 읽으시고, 추후에 더 좋은 방법들을 찾아보시면 좋을 것 같습니다!

RequestAnimationFrame이란?

😝 MDN 문서 링크 😝

The window.requestAnimationFrame() method tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation before the next repaint. The method takes a callback as an argument to be invoked before the repaint.

window.requestAnimationFrame()은 다음 리페인트(색, 위치 등이 계산된 요소들을 브라우저에 그리는 과정) 전에 애니메이션을 업데이트하는 함수를 호출하는 메소드이다.

setInterval()을 사용하여 요소의 top, left 값을 바꾸어주는 방법도 있겠지만,
setInterval 함수의 경우

  • 디스플레이 갱신 전에 캔버스를 여러 번 고치더라도 디스플레이 갱신 바로 직전의 캔버스 상태만 적용이 되기 때문에, 프레임 손실이 발생할 수도 있으며
  • 호출 스택을 깊어지게 하고
  • 브라우저의 다른 탭이 선택된 경우와 같이 실제 화면을 다시 그릴 필요가 없는 경우에도 화면을 다시 그리기 때문에
    이러한 단점들을 보완할 RequestAnimationFrame을 사용해야 한다.

requestAnimationFrame의 사용법은 setTimeoutsetInterval처럼 사용할 때와 비슷하다.

아래 예제들은 모던 자바스크립트 튜토리얼에서 가져온 setIntervalsetTimeout의 예제와
func()의 동작이 화면에 보이는 요소들을 바꾸는 애니메이션 작업이라고 생각했을 때
requestAnimationFrame으로 똑같은 작업을 했을 때의 예제 코드이다.

setInterval

let i = 1;
setInterval(function() {
  func(i++);
}, 100);

setTimeout

let i = 1;
setTimeout(function run() {
  func(i++);
  setTimeout(run, 100);
}, 100);

requestAnimationFrame

let i = 1;
function run() {
  func(i++); 
  window.requestAnimationFrame(run)
}
window.requestAnimationFrame(run)

requestAnimationFramesetTimeout처럼 재귀적으로 호출되어야 한다.

코드를 작성해보자!

😝 작성 전 설계 및 구상

위와 같은 동작을 하는 마자용이 있는 캔버스를 만들기 위한 동작들을 정리해보았다.
(설계할 때 데이터보다 동작을 우선으로 하라는 가르침을 받았기 때문!)

  1. 어떤 키보드든 눌리면 마자용 움짤을 재생한다
    • 마자용 이미지 인덱스를 늘려주는 방법으로 구현
  2. 상하좌우 키보드가 눌리면 그 방향대로 마자용이 움직인다
    • 포지션 값을 갖고 있다가 방향대로 좌표 값을 늘려주는 방법으로 구현
    • 캔버스 너비, 높이보다 초과될 경우 못 움직이게 제한

위 동작들을 수행하기 위해 필요한 변수는

  1. 마자용 이미지 인덱스
  2. 눌려 있는 키 값 (눌려 있지 않을 경우 null)
  3. 마자용의 좌표값

이며, 필요한 함수는

  1. (가장 중요) 캔버스에 렌더링하는 함수
  2. keydown 이벤트를 처리하는 함수
  3. 포지션 변수를 관리하는 함수

가 될 것이다.

😝 코드

😝 전체 소스 코드 링크 😝

변수 선언 및 렌더할 부분 정의

const canvasRef = useRef(null);
const requestAnimationRef = useRef(null);
const positionRef = useRef({ x: 0, y: 0 });
const [pressedKey, setPressedKey] = useState(null);
const [currentFrame, setCurrentFrame] = useState(0);

...

return (
  <canvas
    ref={canvasRef}
    style={{
          backgroundImage: `url(${background})`,
          backgroundSize: "cover",
          overflow: "hidden",
          backgroundRepeat: "no-repeat",
          backgroundPosition: "center",
    }}
  ></canvas>
);

캔버스에다가 뭔가 그리기 위하여 canvas를 변수로 들고 있게끔 하기 위해 canvasRef를 선언하고 그를 canvas 태그 안에 넣어준다.

useEffect()에서 초기화하기

useEffect(() => {
  window.addEventListener("keydown", (e) => {
    e.preventDefault();
    setPressedKey(e.keyCode);
  });
  window.addEventListener("keyup", () => setPressedKey(null));
  requestAnimationRef.current = requestAnimationFrame(render);
  return () => {
    cancelAnimationFrame(requestAnimationRef.current);
  };
});

keyDown과 keyUp 이벤트를 정의해주고, requestAnimationFrame을 호출하여 requestAnimationRef에 넣어준다.

useEffect의 리턴 함수로는 cancelAnimationFrame을 넣어주어 브라우저의 메모리 릭을 막는다.

render()함수로 마자용 띄우기

const render = () => {
  const canvas = canvasRef.current;
  const context = canvas.getContext("2d");
  const wobbuffetsImage = new Image();
  wobbuffetsImage.src = wobbuffetsArray[currentFrame];
  wobbuffetsImage.onload = () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    context.drawImage(
      wobbuffetsImage,
      positionRef.current.x,
      positionRef.current.y
    );
  };
  handleKey();
  requestAnimationRef.current = requestAnimationFrame(render);
};

아무래도 매 프레임마다 마자용의 이미지를 변화시켜주어야 하다 보니,
이미지 초기화 또한 매 프레임마다 하게 되었다.

onload() 콜백 안에 마자용을 그리는 함수를 넣어주지 않으면 자주 끊기는 현상이 발생한다.

handleKey() 함수와 move()함수로 마자용 위치 조정하기

const handleKey = () => {
  switch (pressedKey) {
    case MAP_CONSTANTS.KEY_LEFT:
      move({ x: -1 * MAP_CONSTANTS.SPEED, y: 0 });
      return;
    case MAP_CONSTANTS.KEY_DOWN:
      move({ x: 0, y: -1 * MAP_CONSTANTS.SPEED });
      return;
    case MAP_CONSTANTS.KEY_RIGHT:
      move({ x: MAP_CONSTANTS.SPEED, y: 0 });
      return;
    case MAP_CONSTANTS.KEY_UP:
      move({ x: 0, y: MAP_CONSTANTS.SPEED });
      return;
    case null:
      return;
    default:
      move({ x: 0, y: 0 });
      return;
  }
};

키값에 따라 이동하는 방향을 바꾸어줬다.
아무 키도 눌려있지 않으면 움직임을 주지 않았고,
상하좌우 키를 제외한 키가 눌려 있어도 움직임은 주었다.

const move = ({ x, y }) => {
  const newX = positionRef.current.x + x;
  const newY = positionRef.current.y + y;
  if (newX < 0 || newX > canvasRef.current.width - MAP_CONSTANTS.IMG_WIDTH)
    return;
  if (newY < 0 || newY > canvasRef.current.height - MAP_CONSTANTS.IMG_HEIGHT)
    return;
  positionRef.current = { x: newX, y: newY };
  setCurrentFrame((prev) =>
                  prev < MAP_CONSTANTS.FRAMES_LENGTH ? prev + 1 : 0
                 );
};

positionRef을 관리해주고
현재 마자용의 이미지 프레임을 관리해준다.
프레임 길이보다 벗어나면 냅다 0을 주었다.

결론

아직 손 볼 곳이 많은 코드입니다.
GPU에게 냅다 렌더링을 맡기니 안 좋은 코드를 짜도 부드럽게 돌아가니까 안주하게 됩니다..
나중에 게더타운처럼 소켓 등등에서도 신경쓸 게 많은 서비스를 만들게 된다면 더 최적화된 방법을 알아봐야겠지요!
이 예제가 캔버스를 처음 배우시거나 리액트+캔버스로 뭔가 만들고 싶으신 분들께 도움이 되었으면 좋겠습니다 ㅎㅎ

좋은 웹페이지 즐겨찾기