일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- react native
- react native 세팅
- AWS Access Key
- s3 upload
- js
- firebase 라이브러리
- react native font
- Next.js
- React
- 리액트 네이티브
- 문자열 대소문자 구별
- PongWorld
- 리액트 네이티브 에러
- react native 개발
- aws bucket 정책
- GIT
- 에러
- 백준
- Project
- react native picker
- Access Key 생성
- error
- babel.config.js
- react native CLI
- img upload
- fire base
- 리액트
- 리엑트 네이티브 아이콘
- 문자열 대소문자
- AWS
- Today
- Total
밝을희 클태
React에서 Canvas 태그로 구현한 애니메이션: Lerp와 벡터 활용 본문
HTML의 캔버스 태그를 공부해 보고 싶었는데 뭘 만들까 생각하다가 어둠 속에서 도라에몽을 찾는 게임을 만들고 싶어졌다.
기초는 공식 문서랑 어떤 분이 작성한 개발 블로그를 보고 어느 정도 canvas 태그에 대해 익숙해지고 본격적으로 구현에 들어갔다.
일단 가장 먼저 스트라이트 시트를 골라야 한다
스프라이트 시트(sprite sheet)는 여러 개의 작은 그래픽을 그리드(grid)에 정렬하여 구성한 비트맵 이미지 파일이다.
게임 개발에서 캐릭터의 연속적인 키 포즈를 한 장의 이미지에 구성하여 2D 애니메이션 제작에 사용된다.
나는 도라에몽 스프라이트 시트를 골랐다. 처음에 아래의 이미지를 무료로 받고 사용하려는데 캐릭터의 움직이는 애니메이션을 구현하고 나니 지진이 났다. 내가 잘 못 그렸나 싶었는데 그냥 스프라이트 시트의 각 프레임의 크기가 딱 맞아떨어지지 않아서 발생하는 문제였다. 그래서 그냥 대충 내가 임의로 눈대중으로 잘라서 맞췄다.
그리고 아래 도라에몽은 오른쪽으로 직진 밖에 못한다... 왼쪽으로 갈 때는 왼쪽으로 움직이는 애니메이션을 취해야 하는데 좌우반전해서 그릴 수 있겠지 싶어서 그냥 넘어갔다.
스프라이트 애니메이션 구현
이미지 로드가 완료되면 canvas에 그림을 그린다. 이때 부드러운 애니메이션을 위해 requestAnimationFrame API를 사용했다. 해당 API는 브라우저가 렌더링 될 타이밍에 맞춰 미리 함수를 예약해서 렌더링 주기에 맞는 부드러운 애니메이션을 구현할 수 있다. 여기서 렌더링 주기는 모니터의 주사율을 말한다.
예를 들어, 60Hz 모니터는 초당 60번 화면을 갱신하고, 120Hz 모니터는 초당 120번을 갱신하는데, 이 주기에 맞춰 애니메이션을 실행하니까 각 환경에 최적화된 부드러운 움직임을 보여줄 수 있다.
const [scale, setScale] = useState(0.7);
const width = ~~(980 / 4);
const height = ~~(645 / 2) - 13;
const scaleWidth = useRef(width * scale);
const scaleHeight = useRef(height * scale);
...
const cycleLoopX = [0, 1, 2, 3, 0, 1];
const cycleLoopY = [0, 0, 0, 0, 1, 1];
const img = new Image();
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
let currLoopIdx = 0;
let animationFrameId;
img.src = "/doraemong.png";
img.onload = function () {
init();
};
const drawFrame = (frameX, frameY, canvasX, canvasY) => {
ctx.drawImage(
img,
frameX * width,
frameY * height,
width,
height,
canvasX,
canvasY,
scaleWidth.current,
scaleHeight.current
);
};
const step = () => {
currLoopIdx++;
if (currLoopIdx >= cycleLoopX.length) currLoopIdx = 0;
ctx.clearRect(
0,
0,
document.body.clientWidth,
document.body.clientHeight
);
drawFrame(
cycleLoopX[currLoopIdx],
cycleLoopY[currLoopIdx],
0,
0
);
animationFrameId = requestAnimationFrame(step);
};
const init = () => {
step();
};
위처럼 구현을 하면 발 빠른 도라에몽이 된다. 움짤을 만든 프로그램이 60 fps까지 밖에 지원을 안 해서 그렇지 120 주사율에서 보면 훨씬 빠르게 보인다.
스프라이트 애니메이션 속도 조절
frameSpeed라는 변수를 만들어 requestAnimationFrame() api가 이벤트를 등록할 때마다 캐릭터의 프레임을 변경하는 게 아닌 임의의 수가 넘어가면 프레임을 변경하도록 했다.
그런데 이 방법은 모든 환경에 적합한 방법이 아니다. 왜냐하면 requestAnimationFrame() 자체가 사용자의 모니터 주사율에 맞춰 이벤트를 예약하기 때문이다. 현재 내 모니터는 120Hz라서 초당 120번 step() 함수를 호출한다. 만약 60Hz 모니터를 사용하는 사용자가 해당 사이트에 들어오면, 그 사용자는 초당 60번만 step() 함수를 호출하게 된다. 결국, 내가 보는 도라에몽보다 움직임이 느린 도라에몽을 그 사람은 보게 된다.
그러면 어떻게 해야 할까?
https://gaebarsaebal.tistory.com/63
frameSpeed로 함수 호출 횟수로 하는 게 아닌 시간 기반으로 프레임을 변경하는 방식이 있다.
나는 그냥 재미로 만드는 거기 때문에 그냥 진행했다.
...
let frameSpeed = 0;
...
const step = () => {
frameSpeed++;
if (frameSpeed > 10) {
currLoopIdx++;
if (currLoopIdx >= cycleLoopX.length) currLoopIdx = 0;
frameSpeed = 0;
}
ctx.clearRect(
0,
0,
document.body.clientWidth,
document.body.clientHeight
);
drawFrame(
cycleLoopX[currLoopIdx],
cycleLoopY[currLoopIdx],
0,
0
);
animationFrameId = requestAnimationFrame(step);
};
const init = () => {
step();
};
도라에몽 움직임 구현하기
속도를 지정할 speed와 방향을 지정할 dir 객체를 만들고 init 함수에서 500ms 간격으로 랜덤 한 방향을 만들어 준다.
그리고 벽에 밖을 때는 방향을 돌려주는 로직도 구현해 준다.
const pos = useRef({ x: 0, y: 0 });
...
let speed = 2.5;
let dir = { x: 0, y: 0 };
let animationFrameId;
let intervalId;
...
const reverseTargetDir = (target) => {
if (target === "x")
dir.x = Math.abs(targetDir.x) * (pos.current.x <= 0 ? 1 : -1);
else
dir.y = Math.abs(targetDir.y) * (pos.current.y <= 0 ? 1 : -1);
};
const updatePos = () => {
if (
pos.current.x <= 0 ||
pos.current.x + scaleWidth.current >= canvas.width
)
reverseDir("x");
if (
pos.current.y <= 0 ||
pos.current.y + scaleHeight.current >= canvas.height
)
reverseDir("y");
pos.current.x += dir.x * speed;
pos.current.y += dir.y * speed;
};
const step = () => {
currLoopIdx++;
if (frameSpeed > 10) {
currLoopIdx++;
if (currLoopIdx >= cycleLoopX.length) currLoopIdx = 0;
frameSpeed = 0;
}
updatePos();
ctx.clearRect(
0,
0,
document.body.clientWidth,
document.body.clientHeight
);
drawFrame(
cycleLoopX[currLoopIdx],
cycleLoopY[currLoopIdx],
pos.current.x,
pos.current.y
);
animationFrameId = requestAnimationFrame(step);
};
const init = () => {
intervalId = setInterval(() => {
dir = {
x: Math.random() * 2 - 1,
y: Math.random() * 2 - 1,
};
}, 500);
step();
};
이렇게 조금 이상한 도라에몽이 만들어졌다.
문제가 좀 생겼다. 도라에몽의 pos를 현재 dir * speed로 움직이고 있다. 그런데 dir은 Math.random()을 사용해서 구하기 때문에 만약,
첫 번째 랜덤 값: dir.x = 0.1, dir.y = -0.3
두 번째 랜덤 값: dir.x = 0.8, dir.y = -0.9
이렇게 dir 객체가 초기화되면 도라에몽의 속도가 느리게 가다가 갑자기 빨라지고를 반복하게 된다. 그래서 알아본 게 단위 벡터다. 단위 벡터는 길이가 1인 벡터로, 주로 방향만 나타내는 데 사용된다. 이 단위 벡터에 speed를 곱하면 항상 일정한 속도로 움직일 수 있다.
나도 벡터에 대해서 잘 몰라서 알아본 건데, 벡터는 방향과 크기(또는 길이)를 함께 나타내는 도구라고 한다. 간단히 말해서 어느 방향으로 얼마나 움직일지 표현하는 개념이다.
단위 벡터는 길이가 1이기 때문에, 속도를 조절할 때 아주 유용하다. 단위 벡터에 속도 10을 곱하면 벡터의 속도는 10이 되고, 속도 5를 곱하면 벡터의 속도는 5가 된다.
벡터를 사용하지 않은 현재 코드를 예로 들면
- dir.x = 0.1, dir.y = -0.3, speed = 10
- (0.1 * 10, -0.3 * 10) = (1, -3)
- 벡터의 길이: √1^2 + (-3)^2 = 3.162
- dir.x = 0.9, dir.y = 0.2, speed = 10
- (0.9 * 10, 0.2 * 10) = (9, 2)
- 벡터의 길이: √9^2 + 2^2 = 9.22
이렇게 값에 따라 속도가 달라지는 문제가 생긴다. 그래서 단위 벡터가 필요하다.
단위 벡터 사용 예시
- dir.x = 0.6, dir.y = 0.8, speed = 10
- (0.6 * 10, 0.8 * 10) = (6, 8)
- 벡터의 길이: √6^2 + 8^2 = 10
- dir.x = 0.707, dir.y = 0.707, speed = 10
- (0.707 * 10, 0.707 * 10) = (7.07, 7.07)
- 벡터의 길이: √7.07^2 + 7.07^2 = 10
위처럼 단위 벡터를 사용하면, 방향이 어떻게 설정되든 항상 일정한 속도를 유지할 수 있다.
그러면 어떻게 단위 벡터를 만들 수 있을까?
const angle = Math.random() * Math.PI * 2; // 랜덤한 각도
dir = { x: Math.cos(angle), y: Math.sin(angle) }; // 새로운 방향 설정
위 코드로 쉽게 단위 벡터를 만들 수 있다. 해당 코드를 적용시켜 보면 일정한 속도로 움직이는 도라에몽을 볼 수 있다.
Canvas 반대로 그리기
아쉽게 반대로 그릴 수 있는 방법은 방법은 없었다.
그래도 다른 방법이 있는데, scale()과 translate()을 조합해 이미지를 그릴 때 항상 (0, 0)에서 시작하고, translate()로 위치를 보정하면 반대로 뛰는 모습도 가능하다.
const drawFrame = (frameX, frameY, canvasX, canvasY) => {
ctx.save();
if (dir.x < 0) {
ctx.scale(-1, 1);
ctx.translate(-canvasX - scaleWidth.current, canvasY);
} else ctx.translate(canvasX, canvasY);
ctx.drawImage(
img,
frameX * width,
frameY * height,
width,
height,
0,
0,
scaleWidth.current,
scaleHeight.current
);
ctx.restore();
};
- ctx.save()
- 현재 캔버스의 상태(변환 정보)를 저장
- 이걸 하지 않으면 이후에 그리는 모든 요소에 변경된 값에 덮어 씌우게 된다.
- ctx.scale(-1, 1)
- X축을 기준으로 좌우 반전
- ctx.translate(-canvasX - scaleWidth.current, canvasY)
- 반전된 이미지의 좌표를 올바르게 맞추기 위해 translate()로 이동
- 여기서 -x - width는 X축 반전으로 인해 원래의 위치에서 반대쪽으로 이동된 것을 조정
- ctx.restore()
- 이전에 저장된 상태로 캔버스 좌표계를 복원
기존은 좌표를 옮기면서 그리는 방식이라면 위의 코드는 항상 (0, 0)에 그리고 위치를 옮기는 방식이다.
자연스럽게 이동하기
지금 위에 도라에몽을 보면 움직임이 즉각적으로 움직여 부자연스럽다. 그래서 lerp(선형 보간법)을 사용하기로 했다.
선형 보간법은
두 값 사이를 일정한 비율로 계산해 중간 값을 점진적으로 구하는 수학적 방법
보통 게임 개발, 애니메이션, 그래픽스 분야에서 두 지점 사이를 부드럽게 이동하거나 값을 서서히 변화시키는 데 많이 사용
현재는 dir 벡터만 사용하고 있지만, targetDir 벡터를 추가로 사용해야 한다. 기존에 dir이 담당하던 역할을 targetDir에게 부여하고, 이제 dir 벡터는 lerp을 사용해 점진적으로 방향을 업데이트한다. 이렇게 하면 방향이 급격하게 바뀌지 않고 부드럽게 변화하게 된다.
그리고 점진적으로 방향을 설정하는 과정에서 dir 벡터의 길이가 1보다 커지거나 작아질 수 있기 때문에, 매번 단위 벡터로 정규화해 일정한 속도를 유지해야 한다. 이를 위해 dir 벡터를 업데이트할 때마다 벡터 길이를 1로 맞춰주는 작업이 필요하다.
const normalize = () => {
const length = Math.sqrt(dir.x * dir.x + dir.y * dir.y);
if (length > 0) {
dir.x = dir.x / length;
dir.y = dir.y / length;
}
};
const gradualUpdatePos = (flag) => {
dir.x += (targetDir.x - dir.x) * (flag ? 1 : 0.1);
dir.y += (targetDir.y - dir.y) * (flag ? 1 : 0.1);
normalize()
};
const updatePos = () => {
let flag = 0;
if (
pos.current.x <= 0 ||
pos.current.x + scaleWidth.current >= canvas.width
)
flag = reverseTargetDir("x");
if (
pos.current.y <= 0 ||
pos.current.y + scaleHeight.current >= canvas.height
)
flag = reverseTargetDir("y");
gradualUpdatePos(flag);
pos.current.x += dir.x * speed;
pos.current.y += dir.y * speed;
};
flag는 벽에 부딪힐 때 즉시 방향을 바꾸기 위해 조건으로 두었다. 이렇게 하면 도라에몽이 벽에 닿는 순간 지연 없이 바로 방향을 턴할 수 있다.
또한, 각자의 스타일에 맞게 선형 보간(lerp)의 정도와 speed를 조정하면 된다. 예를 들어, lerp의 비율을 높이면 방향 변화가 더 빠르게 일어나고, speed 값을 줄이면 더 부드럽고 느린 움직임을 만들 수 있다. 이렇게 설정을 조정하면 원하는 움직임의 느낌을 자유롭게 연출할 수 있다.
그러면 이렇게 부드럽게 곡선을 그리며 달리는 도라에몽을 만날 수 있다!
'사이드 프로젝트 > 도라에몽 잡기 [React]' 카테고리의 다른 글
<Canvas> 캐릭터 그리기 도라에몽 (0) | 2024.11.06 |
---|---|
requestAnimationFrame API 주사율 동기화 하기 (0) | 2024.11.06 |
<Canvas> 총알 자국 그리기(사라지는 애니메이션, 회전해서 그리기) (0) | 2024.11.03 |
<Canvas> 애니메이션 중단 시 requestAnimationFrame()의 비동기 문제 해결 (0) | 2024.11.03 |