게임수학 (Game Mathematics)
게임 화면은 매 프레임마다 끊임없이 계산됩니다. 캐릭터가 어디에 있는지 좌표를 저장하고, 어느 방향으로 움직일지 벡터를 구하고, 물체를 돌리기 위해 행렬이나 사원수를 사용하고, 충돌이 났는지 거리와 겹침을 검사하며, 적이 길을 찾을 때는 그래프 알고리즘을 실행합니다. 이 계산의 공통 언어가 바로 게임수학(Game Mathematics)입니다.
이 페이지에서는 게임 개발에서 자주 쓰이는 수학을 하나의 흐름으로 묶어 설명합니다. 각각의 이론을 완전히 새로 증명하기보다는, 어떤 정의가 왜 필요한지, 게임 안에서 어떤 계산으로 나타나는지, 실제로 무엇을 조심해야 하는지를 중심으로 정리하겠습니다.
게임수학은 특정한 한 과목의 이름이 아니라, 게임을 만들 때 반복해서 사용하는 수학적 도구들의 묶음입니다. 크게 보면 다음 네 층으로 이루어집니다.
- 표현의 수학: 점, 벡터, 행렬, 좌표계로 물체와 공간을 표현합니다.
- 움직임의 수학: 미분, 적분, 보간으로 시간에 따른 변화를 계산합니다.
- 판정의 수학: 거리, 각도, 충돌, 시야, 경로 탐색으로 상호작용을 결정합니다.
- 불확실성의 수학: 확률과 기대값으로 랜덤 이벤트와 밸런스를 설계합니다.
따라서 게임수학은 선형대수학, 기하학, 미적분학, 그래프 이론, 확률론, 수치해석이 실제 제작 현장에서 만나는 지점이라고 볼 수 있습니다.
플레이어가 점프 버튼을 눌렀다고 합시다. 먼저 입력 방향을 벡터로 바꾸고, 속도와 가속도로 위치를 갱신합니다. 그 다음 벽과 겹쳤는지 충돌을 검사하고, 카메라 좌표계로 장면을 옮긴 뒤 3차원 정보를 2차원 화면으로 투영합니다. 적 캐릭터는 동시에 격자나 내비게이션 메시 위에서 경로를 찾고, 아이템 상자는 일정 확률로 보상을 떨어뜨립니다. 게임 한 프레임은 사실상 작은 수학 계산들의 연쇄입니다.
이런 곳에 쓰여요
- 캐릭터 이동: 이동 방향을 정규화한 벡터로 만들고, 속도와 시간 $\Delta t$를 곱해 위치를 갱신합니다.
- 3D 장면 렌더링: 모델 좌표를 월드 좌표로 옮기고, 다시 카메라 좌표와 투영 좌표로 바꿉니다.
- 애니메이션: 보간과 스플라인으로 카메라 이동, UI 전환, 캐릭터 회전을 부드럽게 만듭니다.
- 물리와 충돌: 점프 궤적, 반사, 마찰, 히트박스 겹침 판정을 계산합니다.
- 게임 AI: 그래프 위에서 A* 알고리즘으로 길을 찾고, 확률로 행동 변화를 설계합니다.
- 밸런싱: 치명타, 드롭률, 강화 확률의 평균값과 편차를 기대값으로 분석합니다.
선수 지식: 선형대수학, 기하학, 미적분학, 그래프 이론, 확률론, 수치해석
난이도: ★★★☆☆ (고등학교 후반 ~ 대학교 초입)
한 프레임의 계산 흐름
게임수학의 여러 주제는 실제로 한 프레임 안에서 연속으로 이어집니다. 플레이어 입력을 해석한 뒤, 좌표와 벡터로 상태를 표현하고, 물리와 충돌로 상태를 갱신하며, AI와 확률 시스템으로 다음 행동을 결정하고, 마지막에 카메라와 투영으로 화면에 그립니다.
- 입력 해석: 키보드, 패드, 마우스 입력을 방향과 명령으로 바꿉니다.
- 상태 표현: 위치, 속도, 회전, 크기를 점·벡터·행렬로 저장합니다.
- 시뮬레이션: 보간, 물리, 충돌 판정으로 다음 상태를 계산합니다.
- 의사결정: 경로 탐색, 시야 판정, 확률 선택으로 AI 행동을 정합니다.
- 렌더링: 좌표 변환과 투영으로 3차원 장면을 2차원 화면에 그립니다.
실무에서는 입력, 물리, AI, 렌더링을 한 덩어리로 섞기보다 단계별로 나눕니다. 그래야 어느 계산이 어디에서 잘못되었는지 추적하기 쉽고, 프레임률이 바뀌어도 동작을 안정적으로 유지하기 쉽습니다.
좌표계와 벡터
게임 속 모든 것은 결국 숫자로 저장되어야 합니다. 가장 기본이 되는 질문은 두 가지입니다. 물체가 어디에 있는가? 그리고 어느 방향으로 얼마나 움직이는가? 이 두 질문에 답하는 도구가 점과 벡터입니다.
점과 벡터의 차이
점(Point)은 위치만 나타냅니다. 예를 들어 플레이어의 위치가 $(3, 2)$라는 말은, 원점에서 오른쪽으로 3칸, 위로 2칸 떨어진 곳에 있다는 뜻입니다. 반면 벡터(Vector)는 크기와 방향을 함께 나타냅니다. 예를 들어 속도 벡터 $(4, 0)$은 "오른쪽으로 초당 4만큼 이동"을 의미합니다.
이 차이는 매우 중요합니다. 점은 "어디"를 묻고, 벡터는 "어떻게 변하는가"를 묻습니다. 게임 엔진에서는 같은 $(x, y, z)$ 형식으로 저장되더라도, 그것이 위치인지 속도인지 힘인지에 따라 의미가 완전히 달라집니다.
벡터의 크기와 정규화
벡터 $\mathbf{v} = (v_x, v_y)$의 길이, 즉 노름(Norm)은 다음과 같습니다.
$$\|\mathbf{v}\| = \sqrt{v_x^2 + v_y^2}$$방향만 필요할 때는 길이를 1로 만든 단위벡터(Unit Vector)를 사용합니다. 이를 정규화(Normalization)라 합니다.
$$\hat{\mathbf{v}} = \frac{\mathbf{v}}{\|\mathbf{v}\|}$$예를 들어 키보드 입력으로 위쪽과 오른쪽이 동시에 눌리면 방향 벡터는 $(1,1)$이 됩니다. 그대로 이동하면 대각선 속도가 더 빨라집니다. 이를 막으려면 $(1,1)$을 정규화해서 $\left(\frac{1}{\sqrt{2}}, \frac{1}{\sqrt{2}}\right)$로 만든 뒤 속도를 곱해야 합니다.
상하좌우는 각각 길이가 1인 벡터인데, 대각선 입력 $(1,1)$의 길이는 $\sqrt{2}$입니다. 즉, 아무 보정 없이 더하면 대각선이 약 1.414배 빨라집니다. 플레이어가 모서리 방향으로 달릴 때만 빨라지는 현상을 막으려면, 먼저 방향 벡터를 정규화하고 그 다음에 원하는 이동 속도를 곱해야 합니다.
내적과 방향 판정
내적(Dot Product)은 두 벡터가 얼마나 같은 방향을 보는지 측정합니다.
$$\mathbf{a} \cdot \mathbf{b} = a_x b_x + a_y b_y$$단위벡터끼리의 내적은 두 벡터 사이 각도의 코사인과 같습니다.
$$\hat{\mathbf{a}} \cdot \hat{\mathbf{b}} = \cos \theta$$따라서 값이 1에 가까우면 같은 방향, 0이면 서로 직각, -1에 가까우면 반대 방향입니다. 게임에서는 시야 판정, 조준 보정, 표면을 향해 바라보는지 여부를 판단할 때 자주 씁니다.
외적과 좌우 회전
3차원에서의 외적(Cross Product)은 두 벡터에 모두 수직인 벡터를 만듭니다. 카메라의 오른쪽 방향 벡터를 구하거나, 법선 벡터를 만들 때 쓰입니다. 2차원에서는 완전한 3차원 외적 대신 다음 값을 자주 씁니다.
$$a_x b_y - a_y b_x$$이 값의 부호를 보면 한 벡터가 다른 벡터의 왼쪽에 있는지, 오른쪽에 있는지 판단할 수 있습니다. 2D 게임에서 적이 플레이어를 기준으로 어느 쪽에 있는지, 경로가 시계 방향으로 꺾이는지 반시계 방향으로 꺾이는지를 판정할 때 유용합니다.
| 개념 | 수식 | 게임에서의 대표 용도 |
|---|---|---|
| 위치 | $(x, y, z)$ | 캐릭터, 탄환, 카메라의 현재 좌표 |
| 속도 | $\mathbf{v}$ | 이동 방향과 이동량 저장 |
| 내적 | $\mathbf{a} \cdot \mathbf{b}$ | 정면 판정, 시야각 검사, 투영 길이 |
| 외적 | $\mathbf{a} \times \mathbf{b}$ | 법선 계산, 카메라 좌표축 구성 |
단계별 예시: 목표를 향해 이동하기
플레이어가 현재 위치 $A=(2,1)$에 있고, 목표 지점이 $B=(8,5)$라고 합시다. 목표를 향한 방향 벡터는
$$\mathbf{d} = B - A = (6,4)$$입니다. 길이는 $\|\mathbf{d}\| = \sqrt{6^2+4^2} = \sqrt{52} \approx 7.21$이므로, 단위벡터는
$$\hat{\mathbf{d}} \approx \left(\frac{6}{7.21}, \frac{4}{7.21}\right) \approx (0.832, 0.555)$$입니다. 이동 속도가 초당 3칸이고 프레임 시간 간격이 $\Delta t = 0.2$초라면, 이번 프레임의 이동량은
$$\Delta \mathbf{x} = 3 \hat{\mathbf{d}} \Delta t \approx (0.499, 0.333)$$이므로 새로운 위치는 대략 $(2.499, 1.333)$입니다. 이렇게 하면 목표 방향은 유지하면서도 속도는 언제나 3으로 일정하게 유지됩니다.
var dx = targetX - playerX;
var dy = targetY - playerY;
var len = Math.sqrt(dx * dx + dy * dy);
if (len > 0.00001) {
vx = speed * dx / len;
vy = speed * dy / len;
playerX = playerX + vx * dt;
playerY = playerY + vy * dt;
}
특히 길이가 0에 매우 가까운 경우를 먼저 검사하는 이유는, 플레이어가 이미 목표 지점 위에 있을 때 0으로 나누는 오류를 막기 위해서입니다. 게임 구현에서는 이런 예외 처리가 매우 중요합니다.
행렬과 변환
벡터가 "한 물체의 상태"를 표현한다면, 행렬(Matrix)은 그 상태를 다른 상태로 바꾸는 규칙을 표현합니다. 게임에서는 물체를 회전시키고, 확대하고, 다른 좌표계로 옮기는 모든 과정이 결국 변환 행렬로 정리됩니다.
회전과 크기 변환
2차원에서 각도 $\theta$만큼 회전시키는 행렬은 다음과 같습니다.
$$R(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}$$벡터 $\mathbf{x}$에 이 행렬을 곱하면 회전된 벡터 $R(\theta)\mathbf{x}$를 얻습니다. 확대와 축소는 대각행렬로 표현됩니다. 예를 들어 $x$축 방향으로 2배, $y$축 방향으로 0.5배 늘리는 변환은 $\begin{pmatrix}2 & 0 \\ 0 & 0.5\end{pmatrix}$입니다.
이동은 왜 별도의 좌표가 필요합니까?
회전과 크기 변환은 행렬 곱만으로 표현되지만, 평행이동(Translation)은 단순한 $2 \times 2$ 또는 $3 \times 3$ 행렬만으로는 다루기 불편합니다. 그래서 게임 그래픽스에서는 좌표 하나를 더 붙인 동차좌표(Homogeneous Coordinates)를 사용합니다.
$$ \begin{pmatrix} x' \\ y' \\ 1 \end{pmatrix} = \begin{pmatrix} \cos\theta & -\sin\theta & t_x \\ \sin\theta & \cos\theta & t_y \\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} x \\ y \\ 1 \end{pmatrix} $$이 한 행렬 안에 회전과 이동이 동시에 들어갑니다. 3차원에서는 같은 생각을 $4 \times 4$ 행렬로 확장합니다.
로컬 좌표와 월드 좌표
게임 오브젝트는 보통 자기 자신을 기준으로 한 로컬 좌표(Local Coordinates)를 가집니다. 예를 들어 검의 끝점이 손잡이 기준으로 $(0, 1, 0)$에 있다고 합시다. 이 검을 든 캐릭터가 맵의 다른 위치로 이동하고 몸을 회전하면, 검 끝의 실제 위치는 더 이상 $(0, 1, 0)$이 아닙니다. 캐릭터의 이동과 회전을 모두 반영한 뒤의 좌표, 즉 월드 좌표(World Coordinates)로 바뀌어야 합니다.
$$\mathbf{p}_{\text{world}} = M_{\text{world}} \mathbf{p}_{\text{local}}$$이때 $M_{\text{world}}$는 보통 크기 변환(Scale), 회전(Rotation), 이동(Translation)을 합친 행렬이며, 엔진마다 순서는 다를 수 있지만 흔히 $T R S$ 또는 $S R T$ 같은 형태로 사용됩니다.
숫자의 곱셈은 $2 \times 3 = 3 \times 2$처럼 순서를 바꿔도 같지만, 행렬은 일반적으로 그렇지 않습니다. 먼저 회전하고 그 다음 이동하는 것과, 먼저 이동하고 그 다음 회전하는 것은 전혀 다른 결과를 만듭니다. 총을 손에 들고 몸을 돌리는 장면을 떠올리면 직관적입니다. 총이 손을 중심으로 도는지, 월드 원점을 중심으로 빙글 도는지 차이가 생깁니다.
단계별 예시: 로컬 좌표를 월드 좌표로 바꾸기
검 끝이 손잡이 기준 로컬 좌표에서 $(2,1)$에 있다고 합시다. 캐릭터가 먼저 원점 기준으로 90도 회전한 뒤, 월드에서 $(5,2)$만큼 이동한다면 변환 행렬은 다음과 같습니다.
$$ M = \begin{pmatrix} 0 & -1 & 5 \\ 1 & 0 & 2 \\ 0 & 0 & 1 \end{pmatrix}, \qquad \mathbf{p}_{\text{local}} = \begin{pmatrix} 2 \\ 1 \\ 1 \end{pmatrix} $$따라서 월드 좌표는
$$ \mathbf{p}_{\text{world}} = M \mathbf{p}_{\text{local}} = \begin{pmatrix} 4 \\ 4 \\ 1 \end{pmatrix} $$가 됩니다. 즉, 로컬 공간에서는 손잡이 기준 "오른쪽 2, 위 1"이었던 점이, 캐릭터가 움직인 뒤에는 월드 공간의 $(4,4)$로 옮겨집니다. 캐릭터 본(Bone), 무기, 카메라 부착점, 파티클 생성 위치는 모두 이런 방식으로 부모 변환을 이어받습니다.
| 변환 | 의미 | 게임 예시 |
|---|---|---|
| 크기 변환 (Scale) | 길이를 늘이거나 줄임 | 캐릭터 성장 효과, UI 확대 |
| 회전 (Rotation) | 방향을 바꿈 | 포탑 회전, 카메라 회전 |
| 이동 (Translation) | 위치를 옮김 | 몬스터 스폰, 탄환 발사 |
| 좌표계 변환 | 기준을 바꿈 | 로컬 좌표를 월드 좌표로 변환 |
회전 표현과 사원수
회전은 게임수학에서 가장 자주 등장하면서도 가장 실수하기 쉬운 주제입니다. 단순히 "얼마나 돌렸는가"만 적는 것으로 끝나지 않고, 어떤 표현 방식을 쓸지에 따라 안정성과 보간 품질이 달라집니다.
오일러 각 (Euler Angles)
오일러 각(Euler Angles)은 물체를 $x$축, $y$축, $z$축을 기준으로 차례대로 회전시키는 방식입니다. 사람이 이해하기는 쉽습니다. 예를 들어 "위로 20도, 오른쪽으로 30도, 몸을 10도 기울임"처럼 표현할 수 있기 때문입니다. 그러나 회전 순서에 따라 결과가 달라지고, 특정 자세에서는 축 두 개가 겹쳐 자유도가 줄어드는 짐벌 락(Gimbal Lock) 문제가 생길 수 있습니다.
회전 행렬 (Rotation Matrix)
회전 행렬은 계산과 합성에 강합니다. 카메라의 방향 벡터를 만들거나, 여러 축 회전을 한꺼번에 적용할 때 편리합니다. 다만 행렬 원소가 많아 저장량이 늘고, 반복 계산 중 오차가 쌓이면 직교성이 조금씩 깨질 수 있으므로 보정이 필요할 수 있습니다.
사원수 (Quaternions)
사원수(Quaternions)는 3차원 회전을 안정적으로 표현하는 도구입니다. 자세한 정의는 수 체계의 사원수 섹션을 참고하십시오. 게임에서는 길이가 1인 단위 사원수(Unit Quaternion)를 주로 사용합니다.
$$q = \left(\cos\frac{\theta}{2}, \; u_x \sin\frac{\theta}{2}, \; u_y \sin\frac{\theta}{2}, \; u_z \sin\frac{\theta}{2}\right)$$여기서 $(u_x, u_y, u_z)$는 회전축의 단위벡터이고, $\theta$는 회전각입니다. 이 표현은 축과 각도를 자연스럽게 담고 있으며, 회전 합성도 안정적으로 처리할 수 있습니다.
부드러운 회전 보간: SLERP
카메라나 캐릭터를 부드럽게 돌릴 때는 단순한 성분별 선형보간보다 구면선형보간(SLERP, Spherical Linear Interpolation)이 더 자연스럽습니다.
$$q(t) = \operatorname{slerp}(q_0, q_1; t), \quad 0 \le t \le 1$$이 보간은 단위 사원수의 표면 위를 일정한 각속도로 따라가므로, 시작과 끝 회전 사이를 가장 자연스럽게 이어 줍니다. 카메라가 갑자기 비틀리거나, 캐릭터가 긴 경로로 돌아가는 현상을 줄일 수 있습니다.
수치 예시: $y$축으로 90도 회전
회전축이 $y$축 방향 단위벡터 $(0,1,0)$이고 회전각이 90도라면, 단위 사원수는
$$q = \left(\cos\frac{\pi}{4}, 0, \sin\frac{\pi}{4}, 0\right) = \left(\frac{\sqrt{2}}{2}, 0, \frac{\sqrt{2}}{2}, 0\right)$$가 됩니다. 숫자만 보면 낯설지만, 실제로는 "위쪽 축을 기준으로 정확히 90도 돌린다"는 정보를 압축한 것입니다. 엔진 내부에서는 이 값을 회전 행렬로 바꾸거나, 현재 자세와 목표 자세 사이를 보간하는 데 사용합니다.
$q$와 $-q$는 같은 회전을 나타냅니다. 따라서 보간 전에 두 사원수의 내적이 음수이면 한쪽의 부호를 뒤집어, 긴 호를 따라 빙 돌아가는 현상을 막는 것이 일반적입니다. 여러 번 곱셈을 반복한 뒤에는 길이가 1에서 조금 벗어날 수 있으므로 다시 정규화하는 습관도 중요합니다.
| 표현 방식 | 장점 | 주의점 | 주요 용도 |
|---|---|---|---|
| 오일러 각 | 직관적, 편집하기 쉬움 | 짐벌 락, 순서 의존 | 에디터 UI, 간단한 상태값 |
| 회전 행렬 | 합성과 변환에 강함 | 원소가 많고 오차 보정 필요 | 렌더링 파이프라인, 좌표 변환 |
| 사원수 | 안정적, 보간 품질이 좋음 | 직관이 약함 | 캐릭터 회전, 카메라, 애니메이션 |
카메라와 투영
3차원 게임이 결국 2차원 화면에 보인다는 사실은 매우 중요합니다. 즉, 게임은 단순히 물체를 배치하는 데서 끝나지 않고, 공간 정보를 화면 정보로 바꾸는 과정까지 계산해야 합니다. 이 변환이 카메라와 투영입니다.
세 가지 좌표계
보통 3차원 렌더링은 다음 세 좌표계를 거칩니다.
- 모델(로컬) 좌표: 물체 자체를 기준으로 한 좌표
- 월드 좌표: 게임 세계 전체를 기준으로 한 좌표
- 뷰 좌표: 카메라를 원점으로 본 좌표
그 다음에 투영 행렬을 거쳐 화면에 그릴 수 있는 2차원 위치로 바뀝니다. 이를 한 줄로 쓰면 다음과 같습니다.
$$\mathbf{p}_{\text{clip}} = P V M \mathbf{p}_{\text{local}}$$여기서 $M$은 모델 행렬, $V$는 뷰 행렬, $P$는 투영 행렬입니다.
원근 투영과 직교 투영
원근 투영(Perspective Projection)에서는 멀리 있는 물체가 작게 보입니다. 사람 눈과 카메라 렌즈에 가까운 방식입니다. 반면 직교 투영(Orthographic Projection)에서는 거리에 관계없이 같은 크기로 보입니다. 등거리 전략 게임이나 타일맵 편집기에서는 직교 투영이 더 편한 경우가 많습니다.
핵심은 같은 길이도 카메라에서 멀어질수록 더 작은 각도를 차지한다는 점입니다. 단순한 핀홀 카메라 모형에서는 대략 $x_{\text{screen}} \propto \frac{x}{z}$ 형태가 나타납니다. 즉, 깊이 $z$가 두 배가 되면 화면에서 보이는 크기는 대략 절반으로 줄어듭니다.
| 투영 방식 | 성질 | 잘 맞는 장르 |
|---|---|---|
| 원근 투영 | 가까운 물체가 크게 보임 | FPS, TPS, 3D 액션, 오픈월드 |
| 직교 투영 | 거리와 관계없이 크기가 일정 | 전략 게임, 타일 기반 게임, 편집기 |
수치 예시: 같은 오브젝트가 멀어질 때
단순한 핀홀 카메라에서 초점거리 $f=1$, 물체의 가로 오프셋이 $x=1$이라면 화면 좌표는 대략 $x_{\text{screen}} = \frac{x}{z}$입니다. 깊이가 $z=2$일 때는 $0.5$, 깊이가 $z=4$일 때는 $0.25$가 됩니다. 즉, 카메라에서 두 배 멀어지면 화면에서 차지하는 가로 크기는 절반이 됩니다.
$z$가 0에 가까우면 $\frac{x}{z}$ 값이 매우 커져 수치가 불안정해집니다. 그래서 그래픽스 파이프라인은 보통 근평면(Near Plane)과 원평면(Far Plane)을 두어, 계산 가능한 깊이 범위를 제한합니다.
보간과 애니메이션
게임은 대부분 이산적인 프레임으로 실행되지만, 플레이어는 화면이 부드럽게 이어지기를 기대합니다. 두 상태 사이를 자연스럽게 잇는 계산이 바로 보간(Interpolation)입니다.
선형보간 LERP
가장 기본적인 보간은 선형보간(Linear Interpolation, LERP)입니다. 시작값 $a$, 끝값 $b$, 진행률 $t$가 있을 때:
$$\operatorname{lerp}(a, b; t) = (1-t)a + tb \qquad (0 \le t \le 1)$$$t = 0$이면 시작값, $t = 1$이면 끝값, $t = 0.5$이면 정확히 중간값입니다. 체력 바가 줄어드는 애니메이션, 카메라가 목표 지점을 향해 이동하는 효과, 색상 전환 등에 가장 널리 쓰입니다.
진행률을 구하는 역보간 (Inverse Lerp)
반대로 어떤 값 $x$가 구간 $[a,b]$ 안에서 얼마나 진행되었는지를 알고 싶을 때는 역보간(Inverse Lerp)을 사용합니다.
$$t = \frac{x-a}{b-a} \qquad (a \ne b)$$예를 들어 체력 30이 최대 체력 120 중 몇 퍼센트인지 알고 싶다면 $t = \frac{30}{120} = 0.25$입니다. 이 진행률을 이용해 체력 바의 길이나 색을 계산할 수 있습니다.
부드러운 단계 함수 (Smoothstep)
LERP는 계산이 단순하지만 시작과 끝이 조금 딱딱할 수 있습니다. 속도가 갑자기 생기고 갑자기 멈추기 때문입니다. 이를 완화하려고 부드러운 단계 함수(Smoothstep) 같은 함수를 씁니다.
$$s(t) = 3t^2 - 2t^3$$이 함수는 $t=0$과 $t=1$에서 기울기가 0이므로, 출발할 때와 멈출 때 더 자연스럽게 보입니다.
곡선을 따라 움직이는 스플라인
여러 점을 지나면서도 부드러운 경로가 필요하면 스플라인(Spline)을 사용합니다. 자세한 이론은 수치해석의 보간법에서 다루지만, 게임 관점에서는 "체크포인트들을 지나는 부드러운 경로를 만드는 방법"이라고 이해하면 충분합니다. 카메라 레일, 레이싱 게임의 도로 중심선, 캐릭터 자동 이동 경로에 자주 쓰입니다.
var t = Math.min(elapsed / duration, 1);
var s = t * t * (3 - 2 * t);
cameraX = startX + (targetX - startX) * s;
cameraY = startY + (targetY - startY) * s;
위 코드는 카메라를 목표 지점으로 부드럽게 이동시키는 가장 기본적인 형태입니다. 진행률 $t$를 구한 뒤 smoothstep으로 한 번 다듬고, 그 값을 LERP에 넣어 실제 좌표를 계산합니다.
current = lerp(current, target, 0.1)처럼 매 프레임 같은 비율만큼 따라가는 방식은, 30fps와 144fps에서 서로 다른 움직임을 만듭니다. 정확히 0.5초 동안 이동시키고 싶다면, 지금처럼 $\frac{\text{elapsed}}{\text{duration}}$으로 진행률을 직접 계산하는 편이 안전합니다.
운동과 게임 물리
움직임을 수학적으로 다루려면 위치만으로는 부족합니다. 시간이 흐를 때 위치가 어떻게 변하는지를 알아야 하므로, 속도와 가속도가 필요합니다.
위치, 속도, 가속도
위치(Position)는 현재 있는 곳, 속도(Velocity)는 위치의 변화율, 가속도(Acceleration)는 속도의 변화율입니다. 미적분학적으로는 다음과 같이 씁니다.
$$\mathbf{v}(t) = \frac{d\mathbf{x}}{dt}, \qquad \mathbf{a}(t) = \frac{d\mathbf{v}}{dt}$$게임은 연속 시간을 그대로 계산하지 않고, 프레임 단위의 작은 시간 간격 $\Delta t$로 잘라서 근사합니다. 가장 단순한 오일러 방법(Euler Method)은 다음과 같습니다.
$$\mathbf{v}_{n+1} = \mathbf{v}_n + \mathbf{a}_n \Delta t$$ $$\mathbf{x}_{n+1} = \mathbf{x}_n + \mathbf{v}_n \Delta t$$실무에서는 위치를 새 속도로 갱신하는 반-암시적 오일러(Semi-Implicit Euler)도 자주 씁니다.
$$\mathbf{v}_{n+1} = \mathbf{v}_n + \mathbf{a}_n \Delta t$$ $$\mathbf{x}_{n+1} = \mathbf{x}_n + \mathbf{v}_{n+1} \Delta t$$아래 코드 예시는 두 번째 방식에 해당합니다. 점프나 낙하 같은 기본 운동에서는 이 방식이 조금 더 안정적으로 동작하는 경우가 많습니다. 더 자세한 수치해법은 수치해석의 ODE 수치해법을 참고하십시오.
점프와 포물선 운동
중력이 일정하다고 가정하면, 위쪽을 양의 방향으로 잡았을 때 점프 높이는 다음 식으로 설명할 수 있습니다.
$$y(t) = y_0 + v_0 t - \frac{1}{2} g t^2$$여기서 $y_0$는 시작 높이, $v_0$는 점프 순간의 초기 속도, $g$는 중력 가속도입니다. 이 식은 캐릭터 점프, 탄환 궤적, 던진 물체의 움직임을 이해하는 기본 모델입니다.
예를 들어 초기 속도 $v_0 = 7$, 중력 가속도 $g = 9.8$이라면 최고점 도달 시간은 $t = \frac{v_0}{g} \approx 0.714$초입니다. 시작점보다 더 올라가는 높이는 $\frac{v_0^2}{2g} = 2.5$입니다. 따라서 점프 감각을 설계할 때는 원하는 최고 높이와 체공 시간을 먼저 정한 뒤, 거기에 맞추어 $v_0$와 $g$를 역으로 정하는 방법이 자주 쓰입니다.
프레임률 독립성과 고정 시간 간격
같은 게임이라도 어떤 컴퓨터는 30fps, 어떤 컴퓨터는 144fps로 실행될 수 있습니다. 이때 프레임마다 같은 거리만 이동시키면, 빠른 컴퓨터일수록 캐릭터가 더 빨라지는 문제가 생깁니다. 그래서 이동량을 항상 시간과 함께 계산해야 합니다.
$$\Delta \mathbf{x} = \mathbf{v} \Delta t$$실무에서는 물리 계산을 더 안정적으로 만들기 위해 고정 시간 간격(Fixed Time Step)을 자주 사용합니다. 즉, 화면은 가변 프레임으로 그리더라도 물리 계산은 예를 들어 1/60초 간격으로만 진행하는 방식입니다.
수치 적분은 근사 계산이므로, 한 번에 너무 긴 시간을 건너뛰면 충돌을 놓치거나 진동이 폭발할 수 있습니다. 특히 스프링, 강체, 고속 탄환처럼 빠르게 변하는 시스템일수록 작은 시간 간격 또는 더 안정적인 적분기가 필요합니다.
vy = vy + gravity * dt;
playerY = playerY + vy * dt;
if (playerY > groundY) {
playerY = groundY;
vy = 0;
}
이 코드는 가장 단순한 점프 갱신 예시입니다. 중력으로 속도를 먼저 갱신하고, 그 속도로 위치를 움직입니다. 마지막에는 땅을 뚫고 내려가지 않도록 위치를 보정합니다.
충돌 판정과 반응
게임에서 충돌은 두 단계로 나뉩니다. 먼저 "부딪혔는가?"를 판정하고, 그 다음 "부딪혔다면 어떻게 반응할 것인가?"를 계산합니다. 첫 단계는 기하학의 문제이고, 두 번째 단계는 벡터와 물리의 문제입니다.
원 충돌과 거리
반지름이 $r_1$, $r_2$인 두 원의 중심이 $\mathbf{c}_1$, $\mathbf{c}_2$일 때, 두 원이 충돌하는 조건은 중심 거리의 길이가 반지름 합 이하라는 것입니다.
$$\|\mathbf{c}_1 - \mathbf{c}_2\| \le r_1 + r_2$$이 판정은 탄막 게임의 원형 히트박스, 범위 공격, 간단한 2D 충돌 판정에 자주 쓰입니다.
AABB 축 정렬 경계 상자
AABB(Axis-Aligned Bounding Box)는 축과 평행한 직사각형 또는 직육면체입니다. 회전하지 않는 박스 충돌은 매우 빠르게 검사할 수 있습니다. 2차원에서 두 AABB가 겹치는 조건은 다음과 같습니다.
$$x_{1,\min} \le x_{2,\max}, \quad x_{2,\min} \le x_{1,\max}$$ $$y_{1,\min} \le y_{2,\max}, \quad y_{2,\min} \le y_{1,\max}$$즉, $x$축 구간이 겹치고 $y$축 구간도 겹쳐야 합니다. 월드에 있는 수천 개 오브젝트를 빠르게 거르는 넓은 단계(Broad Phase) 충돌 검사에 매우 자주 쓰입니다.
법선과 반사 벡터
공이 벽에 튕기는 장면을 생각해 봅시다. 벽의 바깥쪽 수직 방향을 법선 벡터(Normal Vector) $\mathbf{n}$이라고 하면, 입사 속도 $\mathbf{v}$의 반사 벡터 $\mathbf{r}$은 다음과 같습니다.
$$\mathbf{r} = \mathbf{v} - 2(\mathbf{v} \cdot \mathbf{n})\mathbf{n}$$이 식은 입사 방향을 법선 방향으로 한 번 투영한 뒤, 그 성분을 반대편으로 뒤집는다는 뜻입니다. 핀볼, 벽 튕김, 레이 반사, 거울 효과 등에 직접 쓰입니다.
겹쳤다면 어느 쪽으로 밀어내야 합니까?
AABB끼리 이미 겹쳤다면, 가장 적게 움직여도 분리되는 방향으로 밀어내는 것이 자연스럽습니다. 이를 최소 이동 벡터(Minimum Translation Vector)를 찾는다고 생각할 수 있습니다. 2D 플랫폼 게임에서는 보통 $x$축 겹침과 $y$축 겹침 중 더 작은 쪽으로만 분리합니다.
var overlapX = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX);
var overlapY = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY);
if (overlapX > 0 && overlapY > 0) {
if (overlapX < overlapY) {
playerX = playerX + (playerCenterX < wallCenterX ? -overlapX : overlapX);
vx = 0;
} else {
playerY = playerY + (playerCenterY < wallCenterY ? -overlapY : overlapY);
vy = 0;
}
}
겹침이 더 작은 축으로만 분리하면, 벽에 살짝 닿았을 때 플레이어가 대각선으로 밀려나는 현상을 줄일 수 있습니다. 바닥 충돌에서는 세로 분리가 선택되므로 "착지"를 판단하기 쉽고, 옆면 충돌에서는 가로 분리가 선택되므로 벽 밀기 처리도 단순해집니다.
한 프레임 동안 이동한 거리가 물체 두께보다 크면, 검사 시점에는 이미 벽 반대편에 있을 수 있습니다. 이를 터널링(Tunneling)이라 하며, 레이캐스트, 선분-박스 교차, 스윕 테스트 같은 연속 충돌 검사가 필요합니다.
경로 탐색과 게임 AI
적이 플레이어를 향해 움직이거나, NPC가 목적지까지 우회해서 가는 문제는 결국 그래프(Graph) 위의 경로 탐색 문제입니다. 맵의 칸, 교차점, 내비게이션 메시의 꼭짓점을 노드로 보고, 이동 가능한 연결을 간선으로 보면 됩니다.
너비 우선 탐색, 다익스트라, A* 탐색
너비 우선 탐색(Breadth-First Search, BFS)은 모든 간선 비용이 같을 때 가장 간단한 최단 경로 알고리즘입니다. 다익스트라 알고리즘(Dijkstra's Algorithm)은 비용이 서로 달라도 음수가 아니면 올바른 최단 경로를 구합니다. 자세한 증명과 예시는 그래프 이론의 최단 경로 섹션에서 다룹니다.
게임에서는 보통 A* 탐색(A* Search)이 더 실용적입니다. 현재까지 실제로 쓴 비용 $g(n)$에 더해, 목표까지 앞으로 얼마나 남았는지 추정하는 휴리스틱 $h(n)$를 사용하기 때문입니다.
$$f(n) = g(n) + h(n)$$격자 맵에서 상하좌우 이동만 허용한다면, 휴리스틱으로 맨해튼 거리(Manhattan Distance)
$$h(n) = |x_n - x_g| + |y_n - y_g|$$를 자주 씁니다. 이 값은 실제 최단 거리보다 과장되지 않으므로, A*가 올바른 최단 경로를 유지하면서도 더 적은 칸을 탐색하도록 도와줍니다.
다익스트라는 출발점에서 바깥으로 원형처럼 퍼져 나갑니다. 하지만 A*는 "목표 쪽이 어디인지"를 대략 알고 있으므로, 훨씬 적은 영역만 살펴보고도 길을 찾는 경우가 많습니다. 즉, 최단 경로의 정확성은 유지하면서 쓸데없는 탐색을 줄이는 것입니다.
A* 알고리즘의 핵심 절차
A*는 "지금까지의 실제 비용"과 "앞으로 남았을 것으로 추정되는 비용"을 함께 보며, 가장 유망한 후보를 먼저 꺼내는 알고리즘입니다. 구현할 때는 보통 다음 세 정보를 유지합니다.
| 구성 요소 | 역할 |
|---|---|
| 열린 집합 (Open Set) | 아직 검사할 후보 노드 모음 |
| 부모 정보 (cameFrom) | 도착 후 실제 경로를 복원하기 위한 이전 노드 기록 |
| 비용 표 (gScore) | 출발점에서 각 노드까지의 현재 최선 비용 |
var openSet = [start];
var cameFrom = {};
var gScore = {};
gScore[start.key] = 0;
while (openSet.length > 0) {
var current = popLowestF(openSet, gScore, goal);
if (current.key === goal.key) break;
current.neighbors.forEach(function (next) {
var tentative = gScore[current.key] + cost(current, next);
if (gScore[next.key] === undefined || tentative < gScore[next.key]) {
cameFrom[next.key] = current.key;
gScore[next.key] = tentative;
pushIfMissing(openSet, next);
}
});
}
예를 들어 시작점이 $(0,0)$, 목표가 $(4,3)$라면, 처음 이웃 $(1,0)$과 $(0,1)$의 실제 비용은 모두 $g=1$입니다. 맨해튼 거리 휴리스틱은 두 칸 모두 $h=6$이므로 $f=7$이 됩니다. 이후 한쪽 앞에 장애물이 있어 우회가 필요해지면 그 방향의 $g$가 커지고, A*는 자연스럽게 다른 경로를 더 우선해서 탐색합니다.
휴리스틱 $h(n)$는 실제 남은 최소 비용보다 크지 않아야 합니다. 이 조건을 지키면 A*는 빠르면서도 최단 경로를 보장합니다. 반대로 과장된 휴리스틱을 쓰면 탐색은 빨라질 수 있지만, 최적 경로를 놓칠 수 있습니다.
| 알고리즘 | 언제 적합합니까? | 게임 예시 |
|---|---|---|
| BFS | 모든 칸 이동 비용이 같을 때 | 단순 격자 퍼즐, 범위 탐색 |
| 다익스트라 | 지형마다 비용이 다를 때 | 늪, 도로, 언덕이 섞인 맵 |
| A* | 목표 방향 휴리스틱을 쓸 수 있을 때 | 대부분의 실시간 길찾기 |
확률, 랜덤, 밸런싱
게임의 재미는 종종 랜덤성과 연결됩니다. 치명타, 아이템 드롭, 강화 성공, 가챠, 상태 이상, 적 AI의 행동 선택 모두 확률을 사용합니다. 하지만 "랜덤하다"는 말은 아무렇게나 만든다는 뜻이 아닙니다. 평균값과 편차를 이해해야 플레이 경험을 설계할 수 있습니다.
기댓값
기댓값(Expected Value)은 같은 실험을 아주 많이 반복했을 때 평균적으로 얼마가 나오는지를 나타냅니다.
$$E[X] = \sum_x x P(X = x)$$예를 들어 기본 공격이 100의 피해를 주고, 20% 확률로 2배 치명타가 난다면 평균 피해는 다음과 같습니다.
$$E[\text{damage}] = 0.8 \cdot 100 + 0.2 \cdot 200 = 120$$즉, 이 시스템은 평균적으로 기본 공격력을 20% 올리는 효과와 같습니다.
분산과 체감 손맛
분산(Variance)은 결과가 평균 주위에서 얼마나 흔들리는지 나타냅니다.
$$\operatorname{Var}(X) = E[(X - E[X])^2]$$예를 들어 무기 A는 항상 120의 피해를 주고, 무기 B는 50% 확률로 80 또는 160의 피해를 준다고 합시다. 두 무기 모두 기댓값은 120으로 같지만, 무기 A의 분산은 0이고 무기 B의 분산은 1600입니다. 즉, 평균 DPS는 같아도 무기 B가 훨씬 더 들쑥날쑥하며, 플레이어는 이를 "운이 좋으면 강한 무기" 또는 "불안정한 무기"로 느끼게 됩니다.
희귀 드롭과 체감 확률
확률 $p$로 아이템이 드롭될 때, $n$번 시도 안에 한 번 이상 얻을 확률은 다음과 같습니다.
$$P(\text{at least one success}) = 1 - (1-p)^n$$예를 들어 드롭률이 5%라면, 20번 시도 안에 한 번 이상 얻을 확률은 $1 - 0.95^{20} \approx 0.642$입니다. 즉, 평균적으로는 20번 근처에서 한 번 나올 것 같아도, 실제로는 20번 안에 못 얻는 경우가 여전히 약 35.8%나 됩니다. 플레이어가 "확률이 이상하다"고 느끼는 이유가 여기에 있습니다.
독립 시행과 보정 시스템
단순 랜덤은 보통 독립 시행입니다. 이전에 실패했다고 해서 다음 성공 확률이 자동으로 올라가지는 않습니다. 그래서 긴 실패 연속이 충분히 발생할 수 있습니다. 이를 완화하려고 게임은 종종 천장(pity), 누적 보정, 가중 랜덤을 도입합니다.
천장 시스템의 간단한 수학
하드 천장(Hard Pity)이 $N$회라는 말은, $N$번째 시도에서는 반드시 원하는 결과를 얻는다는 뜻입니다. 기본 확률이 $p$일 때, $n$회 안에 한 번 이상 성공할 확률은
$$ P(\text{success within } n) = \begin{cases} 1-(1-p)^n & (n < N) \\ 1 & (n \ge N) \end{cases} $$로 생각할 수 있습니다. 예를 들어 기본 확률이 2%이고 하드 천장이 50회라면, 49회까지는 일반 랜덤과 같지만 50회 안에는 반드시 성공합니다. 즉, 평균값만 바뀌는 것이 아니라 최악의 실패 길이가 50으로 잘려 플레이어 경험의 분산이 크게 줄어듭니다.
| 설계 요소 | 수학적 핵심 | 게임에서의 의미 |
|---|---|---|
| 치명타 | 기댓값 | 평균 화력을 계산할 수 있습니다. |
| 희귀 드롭 | $1-(1-p)^n$ | 여러 번 시도했을 때의 체감 성공률을 구합니다. |
| 천장 시스템 | 분산 감소 | 지나치게 긴 실패 구간을 줄입니다. |
| 랜덤 AI | 확률 분포 | 패턴이 완전히 고정되지 않게 만듭니다. |
수학적으로 공정한 랜덤이 플레이어에게 항상 공정하게 느껴지지는 않습니다. 짧은 표본에서는 극단적인 결과가 얼마든지 나오기 때문입니다. 그래서 좋은 게임 설계는 "수학적으로 올바른가?"만이 아니라 "플레이어가 받아들이기 좋은 분포인가?"도 함께 고려합니다.
전체 흐름 요약
게임수학의 핵심은 많은 공식을 외우는 데 있지 않습니다. 어떤 상황을 어떤 수학적 대상으로 바꿔 표현할지를 아는 것이 더 중요합니다. 공간 문제는 벡터와 행렬로, 시간 변화는 보간과 적분으로, 판정 문제는 거리와 그래프로, 랜덤 시스템은 확률과 기대값으로 바꿔 생각하면 구조가 잡힙니다.
| 게임 문제 | 핵심 수학 | 함께 보면 좋은 페이지 |
|---|---|---|
| 이동과 조준 | 벡터, 내적, 정규화 | 선형대수학, 기하학 |
| 오브젝트 회전과 배치 | 행렬, 좌표 변환, 사원수 | 선형대수학, 수 체계 |
| 카메라와 화면 출력 | 투영, 좌표계 변환 | 기하학 |
| 애니메이션과 카메라 이동 | 보간, 스플라인 | 수치해석 |
| 점프와 중력 | 속도, 가속도, 수치 적분 | 미적분학, 미분방정식 |
| 충돌 판정 | 거리, 법선, 반사 벡터 | 기하학 |
| 길찾기 | 그래프, 최단 경로, A* | 그래프 이론 |
| 드롭률과 밸런스 | 확률, 기댓값 | 확률론, 통계학 |
정리하면, 게임수학은 "게임에 필요한 잡다한 공식 모음"이 아니라, 게임의 상태와 변화를 정교하게 다루기 위한 공통 계산 언어입니다. 한 번 큰 구조를 이해해 두면, 2D 게임이든 3D 게임이든, 액션이든 전략이든 같은 아이디어가 반복해서 나타나는 것을 볼 수 있습니다.
더 배우려면
- 선형대수학 — 벡터, 행렬, 선형변환, 정사영
- 기하학 — 좌표기하, 변환기하, 벡터기하
- 수 체계 — 사원수의 기본 개념
- 수치해석 — 보간법, 수치 적분, 오일러 방법
- 미분방정식 — 운동 방정식과 수치적 풀이의 배경
- 그래프 이론 — 최단 경로, 탐색, 네트워크 구조
- 확률론 — 확률 분포, 기대값, 반복 시행
- Van Verth, J. M. & Bishop, L. M. — Essential Mathematics for Games and Interactive Applications
- Eberly, D. H. — 3D Game Engine Design
- Millington, I. & Funge, J. — Artificial Intelligence for Games