Canvas애니메이션을 구현할 때 갤럭시 혹은 아이폰 디바이스에서 애니메이션 속도가 다른 이슈 해결하는 법

만약 이전 글을 보시지 않았다면, 보시고 오는것을 추천드립니다.
 

Next.js(React.js)에서 Canvas 사용법 with Typescript

여는 글 최근 Next.js에서 Canvas를 사용할 일이 있었습니다. 기존에 바닐라 자바스크립트에서는 공부를 했었지만, Next.js로는 해본적이 없었고, 따로 정리된 블로그가 딱히 없었습니다. 그래서 최근

noogoonaa.tistory.com


여는 글

최근 Canvas로 개발을 진행하면서, 여러 문제들을 맞닥뜨렸습니다.

그중에서 가장 골치아팠던 문제중 하나가, 이번 주제입니다


문제의 발단

프로젝트를 진행하면 보통 개인 디바이스를 통해서 확인을 하는경우가 많습니다.

저의 디바이스는 갤럭시 S22 울트라이기에 주 테스트는 갤럭시에서 진행했습니다.

어느정도 테스트 가능한 개발이 완료되었고 IOS 테스트 폰에서 테스트를 진행해 보니, 이상하게 화면 터치 시 갤럭시와 IOS의 canvas 애니메이션이 다른것을 발견했습니다.


사전지식

기본적으로 여러분들이 60Hz이상의 주사율을 가진 모니터를 사용하지 않는 이상 디스플레이는 1초에 최대 60번의 렌더링을합니다.

그렇기에 requestAnimationFrame을 사용하면 초당 60번 호출된다는 것이 그 이유입니다.

만약, 여러분들이 144 Hz 게임용 모니터를 사용한다면?

1초에 최대 144번의 렌더링이 이루어지는 것이고 requestAnimationFrame또한, 144번 호출되는것이지요.

그럼 이 내용을 잘 이해하셨다는 가정하의 아래 이야기 해보겠습니다.


캔버스 애니메이션이 다른 이유

일단 캔버스 애니메이션이 다른 이유는 제가 파악하기로는 두가지가 있습니다.
(더 있을 순 있지만, 제가 찾아본 바로는 아래 두가지가 최대였습니다… 시간이 지나면, 더 생길수도…)


첫번째로는 ios 디바이스가 저전력 모드일 경우입니다.

기본적으로 아이폰 13프로 이상 모델에서는 ProMotion기술이 적용되어 최소 10Hz에서 최대 120Hz까지 가변 주사율이 적용됩니다.
(웹 브라우저에서는 60Hz 고정인것 같습니다.)

하지만, 아이폰에서 배터리 저전력 모드로 들어가게 되면 10Hz ~ 60Hz 주사율로 고정이 됩니다. 웹 브라우저에서는 60Hz를 다 해주진 않는것 같구요.

따라서 안드로이드에서는 빠른데, ios에선 느린 현상이 발생하는 겁니다.

 

저전력 모드일 때

 

두번째로는 디스플레이 클릭시 안드로이드의 가변적 120hz 적용으로 인한 주사율 차이입니다.

갤럭시도 120Hz 디스플레이를 지원하고있고, 120Hz 프레임 활성화를 시켜놓으면 부분적으로(화면 클릭 혹은 스크롤 시) 120Hz로 올려줍니다.

그렇기에, 유저가 그냥 애니메이션을 볼 땐 갤럭시에서 60Hz의 주사율을 고정하고 아이폰 또한 60Hz로 같은 프레임으로 보게 되지만, 클릭을 하게 되면 갤럭시 120Hz, 아이폰 60Hz로 상이판 프레임을 보게 됩니다.

 

그럼, 이것을 어떻게 해결해야 할까요?


ios 디바이스가 저전력 모드일 경우 해결 방법

이건, 현재로서는 답이 없습니다.

네이티브가 아닌 브라우저에서 이 문제를 해결하기 위해서 할 수 있는 방법으로는 유저에게 저전력 모드를 꺼 달라고 호소하는 방법밖에는요…

(시간이 지나, 다른 방법이 나올수도 있습니다 :0)


디스플레이 클릭시 갤럭시의 가변적 120hz 적용 이슈 해결

저전력 모드 이슈의 경우는 아직까진 해결법이 없지만 이 이슈는 해결 방법이 있습니다.

120Hz를 60Hz로 강제로 맞추는 방법으로 해결이 가능합니다.


테스트 용 프로젝트 생성

기본적인 테스트 프로젝트는 이 포스팅을 확인하여 만들어주세요

 

Next.js(React.js)에서 Canvas 사용법 with Typescript

여는 글 최근 Next.js에서 Canvas를 사용할 일이 있었습니다. 기존에 바닐라 자바스크립트에서는 공부를 했었지만, Next.js로는 해본적이 없었고, 따로 정리된 블로그가 딱히 없었습니다. 그래서 최근

noogoonaa.tistory.com

그리고 테스트 가능하도록 아래 코드를 추가하겠습니다.

pages/index.tsx

import { NextPage } from "next";
import Canvas from "@/components/Canvas";
import useCanvas from "@/hooks/Canvas/useCanvas";

// 해당 코드 추가
let i = 0;

const Home: NextPage = () => {
    const onAnimation = (ctx: CanvasRenderingContext2D) => {
        ctx.clearRect(0, 0, 500, 500);
        // 상자가 애니메이션 되도록 처리
        ctx.fillRect(i % 300, 20, 30, 30);

        // 애니메이션 처리를 위한 부분
        i = i += 1 * 5;
    };

    const { canvasRef } = useCanvas({
        animate: onAnimation,
    });

    return (
        <>
            <Canvas canvasRef={canvasRef} />
        </>
    );
};

export default Home;

그리고 실행을 하면 상자가 계속에서 좌에서 우로 움직입니다.

위에서 보던 영상과 같은 역할을 하는 프로젝트를 완성하였습니다.


requestAnimationFrame 사전 지식

requestAnimationFrame은 브라우저가 화면을 그리기 이전에, 호출되어지는 API입니다.

이 API는 콜백함수를 실행할 때, 하나의 인자를 전달해주는데,

DOMHighResTimeStamp라는 값을 같이 넘겨줍니다.

복잡하게 파고들면 어렵기에 간단히 말해서 requestAnimationFrame에 전달된 콜백 함수가 호출되는 timestamp라고 이해하면 편합니다.


강제로 프레임을 낮추는 함수 작성

먼저 다 작성한 코드를 보여드리겠습니다.

hooks/Canvas/useCanvas.tsx

import { RefObject, useEffect, useRef, useState } from "react";

interface InitialProps {
	animate: (ctx: CanvasRenderingContext2D) => void;
}

function useCanvas({ animate }: InitialProps) {
	const canvasRef: RefObject<HTMLCanvasElement> =
		useRef<HTMLCanvasElement>(null);
	const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);

	useEffect(() => {
		if (canvasRef?.current) {
			const canvas = canvasRef.current;
			const context = canvas.getContext("2d");

			setCtx(context);
		}
	}, []);

	useEffect(() => {
		let requestId: number;

		const callBackReqestAnimation = (fps: number) => {
			let fpsInterval = 1000 / fps;
			let prevRenderTimestamp: number;

			const cb = (timestamp: number) => {
				if (prevRenderTimestamp === undefined) {
					prevRenderTimestamp = window.performance.now();
				}

				const elapsed = timestamp - prevRenderTimestamp;

				if (elapsed >= fpsInterval) {
					prevRenderTimestamp = timestamp - (elapsed % fpsInterval);
					if (ctx) animate(ctx);
				}
				requestId = window.requestAnimationFrame(cb);
			};
			return cb;
		};

		const initRequestAnimation = (callback: (timestamp: number) => void) => {
			requestId = window.requestAnimationFrame(callback);
		};

		initRequestAnimation(callBackReqestAnimation(60));

		return () => {
			window.cancelAnimationFrame(requestId);
		};
	}, [ctx, animate]);

	return {
		canvasRef,
	};
}

export default useCanvas;

어떤가요? 이해가 되시나요?

만약 이해가 되신다면 당신은 천재! 아래 내용은 더이상 안보셔도 됩니다.

하지만, 전 처음에 이해가 잘 안갔기에 한번 자세히 요목조목 뜯어 보겠습니다.


자세히 살펴보기

사실 이 강제로 프레임을 낮추는 코드는 살펴보면 엄청 간단합니다.
다만, 프레임이라는 개념이 들어갔기에 조금 복잡하게 느껴질 수 있습니다.

 

하지만 괜찮습니다. 아래 내용을 보면 이해가 금방 되실겁니다.

 

아까 사전지식 섹션에서 제가 브라우저는 초당 60프레임을 기준으로 렌더링 한다고 했습니다.

하지만, 문제가 되는것은 120Hz입니다.

 

그렇다면 간단합니다.

120프레임 중 어떠한 조건에 한하여 애니메이션을 그리거나 안그리면 됩니다. 해당 로직으 바로 이 로직입니다.

...
const callBackReqestAnimation = (fps: number) => {
	let fpsInterval = 1000 / fps;
	let prevRenderTimestamp: number;
	const cb = (timestamp: number) => {
		if (prevRenderTimestamp === undefined) {
			prevRenderTimestamp = window.performance.now();
		}

		const elapsed = timestamp - prevRenderTimestamp;

		if (elapsed >= fpsInterval) {
			prevRenderTimestamp = timestamp - (elapsed % fpsInterval);
			if (ctx) animate(ctx);
		}
		requestId = window.requestAnimationFrame(cb);
	};
	return cb;
};
...

좀 더 상세히 파보겠습니다.

 

1초는 1000ms입니다. 그렇다면 우리가 60프레임을 그릴경우 몇 밀리초마다 화면을 렌더링 해줘야 할까요?

바로 16.66666667(이하 17밀리 초)밀리 초 마다 화면을 그려주어야 합니다. 그 로직이 아래 로직입니다.

...
const callBackReqestAnimation = (fps: number) => {
	// 1000밀리 초 / 원하는 프레임 = 렌더링 해야될 밀리초 값
	let fpsInterval = 1000 / fps;
	...
};
...

자 이제, 우리는 17밀리초마다 화면을 그려주어야 한다는것을 알 수 있게 되었습니다.

하지만 이 값을 가지고 진짜 그려야 하는 순간인지 아닌지를 알 수 없습니다.

따라서 함수가 시작되는 시간을 알아야 합니다.

 

아까, 제가 requestAnimationFrame이 받는 콜백 매게변수로 타임스탬프 인자를 하나 전달해 준다고 했습니다.

그럼 이 함수를 가지고 현재 시간이 렌더링을 해야하는 순간인지 아닌지를 판단할 수 있게 됩니다.

**...
let fpsInterval = 1000 / fps;
let prevRenderTimestamp: number;
const cb = (timestamp: number) => {
	// 만약 이전에 한번도 렌더링 된 값이 없다면 현재 시간을 넣어줌
	if (prevRenderTimestamp === undefined) {
		prevRenderTimestamp = window.performance.now();
	}

	// 현재 시간 - 이전 렌더링 된 시간 = 이전 렌더링 이후 경과된 시간
	const elapsed = timestamp - prevRenderTimestamp;

	// 이전 렌더링 이후 경과된 시간이 과연 렌더링 하는 시간과 같은지 판단하는 조건
	// 예를들어 17밀리초 마다 한번씩 그려야 하는데 경과된 시간이 13밀리초라면 그리지 않음
	// 하지만, 17밀리초 마다 한번씩 그려야 하는데 경과된 시간이 26밀리초라면 그림
	if (elapsed >= fpsInterval) {
		// 다음 렌더링 해야될 시간
		// 17밀리초마다 그려야 하지만 26밀리초에 그렸다면 남은 9밀리초는 다음 렌더링 시간에서 제외
		prevRenderTimestamp = timestamp - (elapsed % fpsInterval);

		// 렌더링
		if (ctx) animate(ctx);
	}
	requestId = window.requestAnimationFrame(cb);
};
...**

이게 끝입니다. 어때요 로직을 설명 들으니 간단하죠?

그럼 어디 진짜 잘 동작하는지 확인을 해보겠습니다.

 

화면이 120hz까지 올라가도 애니메이션이 빨라지지 않는것을 확인할 수 있습니다.


기술적인 한계

이 코드에는 극명한 기술적인 한계가 존재합니다.

바로 주사율이 렌더링하는 주사율 보다 낮을때는 대응이 어렵다는 것 입니다.

이 부분은, 추후 좋은 방법이 있다면 또 공유하도록 하겠습니다.


마무리

생각보다 글이 많이 길어졌습니다.

로직을 설명하니 이렇게 길어지는군요…

다음에는 더 짧은 글을 작성할 수 있도록 노력해보겠습니다.

감사합니다.

이 글을 공유하기

댓글