게임 개발 수학
캐릭터를 움직이고, 적이 나를 보는지 판정하고, 카메라를 부드럽게 돌리고, 총알이 벽에 맞았는지 계산하는 데 쓰이는 벡터·행렬·쿼터니언·보간·충돌·적분·난수를 작은 예로 직접 계산해 본다.
목차 (16)
좌표계: 월드, 화면, 그리고 손 방향
게임 속 위치는 모두 어떤 좌표계 위의 숫자다. 같은 점이라도 무엇을 기준으로 하느냐에 따라 숫자가 다르다.
- 월드 좌표: 게임 세계 전체의 기준. 수학 교과서처럼 y가 위로 커지는 경우가 많다.
- 로컬(모델) 좌표: 물체 자신의 중심을 원점으로 한 좌표. 캐릭터의 "앞"은 로컬 좌표에서 늘 같은 방향이다.
- 화면 좌표: 픽셀 단위로, 왼쪽 위가 (0, 0)이고 y가 아래로 커진다. 마우스 위치와 UI가 이 좌표다. 2D 게임에서 월드와 화면을 오갈 때 y 부호를 뒤집는 것을 잊으면 물체가 거꾸로 움직인다.
3D에서는 z축 방향이 엔진마다 다르다. 오른손의 엄지·검지·중지를 x·y·z로 펴면 오른손 좌표계, 왼손이면 왼손 좌표계다. Unity와 Unreal은 왼손, OpenGL 관례와 Godot은 오른손을 쓰고, 위쪽도 y(Unity·Godot)와 z(Unreal·Blender)로 갈린다. 모델이나 애니메이션을 다른 도구에서 가져올 때 축을 바꿔 주지 않으면 뒤집히거나 누워 버린다.
벡터: 위치, 방향, 속도
벡터는 크기와 방향을 함께 가진 값이다. 위치(원점에서의 변위), 속도, 힘, 바라보는 방향이 모두 벡터다. 게임에서 가장 자주 쓰는 연산은 몇 개 되지 않는다.
- 덧셈: 한 벡터의 끝에 다른 벡터를 이어 붙인다. 위치 + 속도 × 시간 = 다음 위치.
- 뺄셈: 목표 위치 − 내 위치 = 나에서 목표로 가는 벡터. 추적·조준의 출발점이다.
- 스칼라 곱: 방향은 두고 길이만 바꾼다.
- 길이 ‖v‖ = √(x² + y²): 거리. 비교만 할 때는 제곱근을 빼고 제곱끼리 비교해 계산을 아낀다.
- 정규화 v / ‖v‖: 길이를 1로 맞춰 "방향"만 남긴다.
- 영벡터 정규화: 길이가 0인 벡터를 정규화하면 0/0이라 NaN이 나오고, NaN은 더하고 곱해도 NaN이라 위치까지 번진다. 멈춰 선 캐릭터의 이동 방향처럼 0이 될 수 있는 벡터는 길이부터 확인한다. Unity의 Vector3.normalized는 너무 작은 벡터에 영벡터를 돌려주도록 정해 두었다.
흔한 실수가 대각선 이동이다. 가로 입력 1과 세로 입력 1을 그대로 속도에 곱하면 (1, 1)의 길이가 √2라 대각선으로 갈 때 속력이 7.07로, 원래 5보다 41% 빨라진다. 입력 벡터를 정규화한 뒤(아날로그 스틱이라면 길이가 1을 넘을 때만 잘라 낸 뒤) 속력을 곱한다.
내적의 쓰임: 시야, 투영, 반사
두 벡터의 내적 a·b = aₓbₓ + a_yb_y = ‖a‖‖b‖cos θ다. 둘 다 단위 벡터면 내적이 곧 사이 각의 코사인이라, 각도를 직접 구하지 않고도 방향 관계를 알 수 있다.
- 앞인가 뒤인가: 바라보는 방향과 대상 방향의 내적이 양수면 앞, 음수면 뒤.
- 시야 안인가: 시야각이 90°면 반각 45°의 코사인 0.707보다 내적이 크면 시야 안이다. acos를 부르지 않고 미리 구한 문턱값과 비교만 한다.
- 투영: v·n(n은 단위 벡터)은 v 가운데 n 방향 성분의 크기다. 속도에서 벽 법선 성분을 빼면 벽을 따라 미끄러지는 속도가, 두 배를 빼면 튕겨 나가는 반사 속도가 된다. 조명 계산의 밝기 n·l(표면 법선과 빛 방향의 내적)도 같은 원리다.
외적: 왼쪽·오른쪽과 법선
3차원 외적 a × b는 a와 b에 모두 수직인 벡터이고, 길이는 두 벡터가 만드는 평행사변형의 넓이다. 방향은 좌표계의 손 방향을 따른다. 삼각형의 두 변을 외적하면 면의 법선이 나와 조명, 뒷면 제거, 경사면 판정에 쓴다. 정점을 감는 순서(반시계·시계)를 바꾸면 법선이 반대로 뒤집히므로, 모델의 감는 순서가 엔진 관례와 맞아야 한다.
2차원에서는 외적의 z 성분 aₓb_y − a_yb_x만 쓴다. 이 값이 양수면 b가 a의 왼쪽(반시계 방향), 음수면 오른쪽이다. 적이 플레이어 쪽으로 왼쪽으로 돌지 오른쪽으로 돌지, 점이 다각형 변의 안쪽인지, 세 점이 반시계로 놓였는지를 모두 이 부호 하나로 판정한다.
행렬과 변환: 순서가 중요하다
물체를 옮기고(이동), 돌리고(회전), 키우는(크기) 변환은 행렬로 쓸 수 있고, 여러 변환을 행렬 곱 하나로 합쳐 정점 수천 개에 한 번에 적용한다. 행렬 곱은 순서를 바꾸면 결과가 달라진다. 열 벡터 관례(v′ = M v)에서 T·R은 "R을 먼저, 다음 T"다.
- 회전 → 이동(T·R): 제자리에서 돈 뒤 옮긴다. 화살표 끝 (1, 0)은 (3, 1)로 간다. 보통 원하는 결과다.
- 이동 → 회전(R·T): 옮긴 뒤 원점을 중심으로 돌아, 물체가 원점 둘레로 크게 휘돌아 (0, 4)에 놓인다. 행성의 공전이나 문의 경첩처럼 일부러 이 효과를 쓰기도 한다.
그래서 게임 엔진은 보통 크기 → 회전 → 이동(M = T·R·S) 순서로 로컬에서 월드로 보내고, 부모–자식 관계(무기를 쥔 손)는 부모의 행렬을 앞에 곱해 쌓는다.
동차 좌표: 이동도 행렬 곱으로
회전과 크기는 2×2 행렬 곱으로 되지만, 이동은 더하기라 곱으로 쓸 수 없다. 그래서 좌표에 성분 하나(w)를 덧붙여 2D는 3×3, 3D는 4×4 행렬을 쓴다. 이것이 동차 좌표다. 마지막 열에 이동량을 넣으면 이동도 곱 하나가 된다.
덤으로 점과 방향이 구분된다. 점은 w = 1이라 이동의 영향을 받고, 방향(속도·법선)은 w = 0이라 받지 않는다. (1, 0)을 이동 (3, 0) 행렬에 곱하면 점으로서는 (4, 0), 방향으로서는 (1, 0) 그대로다. 4×4 행렬의 w 자리는 원근 투영에서도 쓰인다. 멀리 있는 것을 작게 그리려고 투영 뒤 x, y를 w로 나누는데, 자세한 이야기는 컴퓨터 그래픽스 노트의 렌더링 파이프라인에 있다.
오일러 각과 짐벌 락
3D 회전을 가장 직관적으로 쓰는 방법은 오일러 각, 곧 요(좌우)·피치(위아래)·롤(기울기) 세 각도를 정해진 순서로 적용하는 것이다. 에디터의 회전 입력 칸이 대개 이것이다. 문제는 피치가 ±90°가 되면 롤 축이 요 축과 같은 방향으로 겹쳐, 세 각도 가운데 둘이 같은 회전을 하게 되는 짐벌 락이다. 이때는 어떤 요·롤 조합을 넣어도 회전 자유도가 하나 사라진다.
또 오일러 각은 두 자세 사이를 각도별로 보간하면 이상한 경로로 돌고, 같은 자세를 나타내는 각도 조합이 여럿이라 비교도 까다롭다. 그래서 입력·표시는 오일러 각으로 하되, 내부 계산과 보간은 쿼터니언으로 하는 엔진이 많다.
쿼터니언과 slerp
쿼터니언은 수 네 개 (w, x, y, z)로 회전을 나타낸다. 축 u를 중심으로 θ만큼 도는 회전은 (cos(θ/2), u·sin(θ/2))이다. 각도가 반으로 들어가는 것이 특징이라, 160° 회전은 4차원 단위 구 위에서 80°만큼 떨어진 두 점 사이의 일이 된다. 짐벌 락이 없고, 회전을 합치는 곱이 4×4 행렬 곱보다 싸며, 수치 오차로 길이가 조금 흐트러져도 정규화 한 번으로 바로잡힌다.
- lerp: 성분을 그대로 섞으면 구의 현을 따라가 길이가 1보다 짧아진다. 0°→160°의 가운데에서 길이가 0.77이다.
- nlerp: lerp한 뒤 정규화한다. 싸고 늘 같은 경로로 가지만, 각속도가 고르지 않아 가운데에서 빨라진다. 각도 차이가 작으면 거의 티가 나지 않아 애니메이션 블렌딩에 많이 쓴다.
- slerp(구면 선형 보간): 구의 호를 따라 일정한 각속도로 간다. 카메라가 천천히 돌아야 할 때처럼 속도가 고르게 보여야 할 때 쓴다. q와 −q는 같은 회전이므로, 내적이 음수면 한쪽 부호를 뒤집어 짧은 쪽으로 돌게 한다.
보간과 이징
선형 보간 lerp(a, b, t) = a + (b − a)·t는 t가 0에서 1로 갈 때 a에서 b로 고르게 간다. 위치·색·음량·카메라 거리 등 거의 모든 값의 전환에 쓰인다. 다만 일정한 속도로 갑자기 출발하고 멈추면 기계적으로 보이므로, t를 이징 함수에 한 번 통과시켜 속도에 표정을 준다.
- smoothstep 3t² − 2t³: 양 끝의 기울기가 0이라 부드럽게 출발하고 멈춘다. t = 0.25에서 0.15625이다. 셰이더에서도 경계를 부드럽게 할 때 표준처럼 쓴다.
- ease-in(t²): 천천히 출발해 빨라진다. 떨어지는 물체처럼 가속하는 느낌.
- ease-out(1 − (1 − t)³): 빠르게 출발해 감속하며 멈춘다. UI 창이 열릴 때 가장 자연스러워 보이는 경우가 많다.
카메라가 목표를 따라가게 할 때 흔히 매 프레임 pos = lerp(pos, target, 0.1)을 쓴다. 이것은 프레임마다 남은 거리의 10%를 가는 것이라 프레임이 많을수록 빨리 따라간다. 1초 뒤 30fps에서는 95.8%, 60fps에서는 99.8%를 간다. 비율을 1 − e−λ·dt로 바꾸면 프레임률과 상관없이 같은 곡선이 된다. 애니메이션 곡선과 블렌딩은 게임 개발 › 애니메이션에서 더 다룬다.
곡선: 베지어와 캣멀–롬
베지어 곡선은 조절점 몇 개로 매끄러운 곡선을 정한다. 3차 베지어는 점 네 개를 쓰고, 양 끝 점은 지나지만 가운데 두 점은 곡선을 끌어당기기만 한다. 펜 툴, 폰트 윤곽, 애니메이션 곡선 편집기, 미사일의 휘는 궤적이 모두 베지어다. 곡선 위의 점은 드 카스텔조 알고리즘, 곧 lerp를 되풀이해 구한다.
조절점 P₀~P₃ (0, 0) (1, 3) (4, 3.5) (5, 0.5). 곡선은 P₀에서 출발해 P₃에 닿지만 P₁·P₂는 지나지 않고 끌어당기기만 한다. t = 0.4인 점을 찾자.
베지어는 곡선이 조절점을 지나지 않아, "이 점들을 차례로 지나는 길"을 만들 때는 불편하다. 캣멀–롬 스플라인은 지정한 점을 모두 지나고, 각 점의 접선을 앞뒤 점을 잇는 방향으로 자동으로 정한다. 카메라 레일, 순찰 경로, 레이싱 게임의 코스 중심선에 쓴다. 곡선의 매개변수 t가 거리에 비례하지 않으므로, 일정한 속도로 움직이려면 곡선 길이를 표로 만들어 t를 거리로 바꿔 쓴다.
충돌 판정
물체마다 실제 모양 대신 단순한 도형(충돌체)을 씌워 판정한다. 싸고 빠른 순서로 먼저 걸러 내고, 걸린 것만 정밀하게 본다.
- AABB(축 정렬 경계 상자): x 구간끼리, y 구간끼리 모두 겹치면 충돌. 비교 네 번이면 된다. 밀어낼 때는 겹친 양이 작은 축으로 그만큼 민다.
- 원–원(구–구): 중심 거리가 반지름 합보다 작으면 충돌. 거리² < (반지름 합)²으로 비교해 제곱근을 피한다.
- 광선 판정: 총알, 시야, 마우스 클릭은 광선 o + t·d로 다룬다. 원(구)과는 이차방정식의 작은 근, 평면과는 t = (p − o)·n / (d·n) 한 줄이다. t가 맞은 곳까지의 거리다.
돌아간 상자나 볼록 다각형끼리는 분리축 정리(SAT)를 쓴다. 두 볼록 도형이 떨어져 있으면, 둘을 투영한 그림자가 겹치지 않는 축이 반드시 있다. 2D에서는 두 도형의 변 법선만 후보로 보면 충분하다. 한 축에서라도 투영 구간이 떨어지면 충돌이 아니고, 모든 축에서 겹치면 충돌이며 가장 적게 겹친 축이 밀어낼 방향이다. 물리 엔진의 실제 구조(넓은 단계·좁은 단계, 연속 충돌 검출)는 게임 개발 › 물리에 있다.
물리 적분과 고정 시간 간격
물리 엔진은 매 걸음 힘에서 가속도를, 가속도에서 속도를, 속도에서 위치를 조금씩 쌓는다(수치 적분). 어떤 순서로 쌓느냐가 안정성을 가른다.
- 명시적 오일러: 지금 속도로 위치를 옮기고, 그다음 속도를 바꾼다. 가장 단순하지만 용수철·궤도처럼 진동하는 계에서 에너지가 매 걸음 불어나 결국 폭발한다.
- 반암시적(심플렉틱) 오일러: 속도를 먼저 바꾸고, 새 속도로 위치를 옮긴다. 계산량은 같은데 에너지가 오르내리기만 할 뿐 쌓이지 않는다. 대부분의 게임 물리 엔진이 쓰는 방식이다.
- 베를레: 속도 없이 지금 위치와 이전 위치로 다음 위치를 낸다(x′ = 2x − x₋₁ + a·dt²). 거리 제약을 맞추기 쉬워 천·밧줄·래그돌 시뮬레이션에 많이 쓴다. 아래 그림은 위치가 똑같이 나오는 속도 베를레 꼴로 계산해 에너지도 함께 보였다.
처음: x = 1, v = 0, 에너지 ½v² + ½x² = 0.50. 점선은 정확한 해 cos t다. 세 방법 모두 한 걸음에 dt = 0.3초씩 간다.
적분 결과는 dt에 따라 달라진다. 정지 상태에서 1초 동안 떨어진 거리의 정답은 ½g·t² = 4.905m인데, 명시적 오일러는 30fps에서 4.741m, 60fps에서 4.823m, 반암시적 오일러는 5.068m와 4.987m다. 프레임률마다 점프 높이가 달라지고, 멀티플레이에서는 기기마다 결과가 어긋난다.
그래서 물리는 렌더링과 떼어 고정 시간 간격으로 돌린다. 프레임마다 흐른 시간을 통(누산기)에 쌓고, 고정 간격(예: 1/60초)만큼씩 덜어 내며 물리를 한 걸음씩 돌린다. 남은 시간은 다음 프레임으로 넘기고, 화면은 남은 비율 α로 이전 상태와 현재 상태를 보간해 그린다. 프레임이 아주 느려 걸음이 끝없이 쌓이는 것(죽음의 소용돌이)을 막으려 한 프레임의 최대 걸음 수도 둔다.
난수, 노이즈, 확률표
게임의 난수는 거의 모두 의사 난수 생성기(PRNG)다. 시드라는 숫자 하나에서 출발해 정해진 계산으로 수열을 만든다. 같은 시드면 늘 같은 수열이 나와, 리플레이·멀티플레이 동기화·버그 재현·같은 시드로 같은 맵을 만드는 절차적 생성이 가능해진다. 예컨대 이 노트가 쓰는 mulberry32는 시드 42에서 (0.601, 0.448, 0.852), 시드 43에서 (1.000, 0.276, 0.529)를 낸다. 시드가 1만 달라도 수열은 전혀 다르다. 용도별로 생성기를 따로 두면(전투용, 맵 생성용) 한쪽 호출 횟수가 바뀌어도 다른 쪽 결과가 흔들리지 않는다. 보안이 필요한 곳(도박성 보상의 서버 판정 등)에는 암호학적 난수를 쓴다(암호학 노트).
지형·구름·흔들림처럼 "매끄럽게 무작위인" 값이 필요하면 노이즈를 쓴다. 정수 격자점마다 시드로 정한 무작위 값을 두고 사이를 부드럽게 이으면 값 노이즈, 격자점마다 기울기를 두고 이으면 그레이디언트(펄린) 노이즈다. 그레이디언트 노이즈는 격자점에서 늘 0이라 격자 무늬가 덜 드러난다. 주파수를 2배, 진폭을 ½배씩 하며 여러 겹(옥타브)을 더하면 큰 언덕 위에 작은 돌기가 얹힌 자연스러운 모양(프랙탈 노이즈, fBm)이 된다. 노이즈로 맵을 만드는 방법은 게임 개발 › 절차적 생성에 있다.
아이템 드롭은 가중치 확률표로 고른다. 가중치를 차례로 누적한 띠 위에 0~1 난수 × 합계를 떨어뜨려, 그 값이 들어간 칸을 고른다. 낮은 확률은 생각보다 자주 안 나온다. 5% 아이템을 20번 뽑아도 한 번이라도 나올 확률은 64.2%에 그친다. 그래서 일정 횟수 안 나오면 확률을 올리거나 확정하는 천장(pity) 장치를 둔다.
삼각함수와 atan2
각도와 방향 벡터는 삼각함수로 오간다. 각도 θ의 방향은 (cos θ, sin θ)이고, 거꾸로 벡터 (dx, dy)의 각도는 atan2(dy, dx)다. atan(dy/dx)를 쓰면 나눗셈에서 부호 정보가 사라져, 왼쪽 위와 오른쪽 아래를 구별하지 못하고 dx = 0이면 0으로 나누게 된다. atan2는 두 부호를 따로 받아 −180°~180° 전체를 제대로 돌려준다.
- 조준: 포탑에서 목표로 가는 벡터의 atan2가 포탑이 돌 각도다. 총알 속도는 (cos θ, sin θ) × 속력.
- 원운동·흔들림: (cos t, sin t)로 원을 돌고, sin을 시간에 곱해 둥실둥실 떠 있는 효과를 낸다. 주기적인 신호의 수학은 신호 처리 노트의 푸리에 변환으로 이어진다.
- 각도 차이: 170°에서 −170°로 돌 때 그냥 빼면 −340°라 거의 한 바퀴를 돈다. 차이를 −180°~180°로 감싸야 20°만 돈다.
부동소수점: ==로 비교하지 않는다
게임의 좌표와 속도는 거의 다 부동소수점이고, 부동소수점은 실수를 유한한 비트로 반올림해 담는다. 0.1과 0.2는 이진수로 끝나지 않아 64비트로 더해도 0.30000000000000004가 된다. 그래서 계산한 두 값을 ==로 비교하면 같아야 할 것이 다르다고 나온다. 차이가 작은 값 ε 이내인지로 비교하되, 고정된 ε은 아주 큰 수에서는 반올림 간격보다 작아 늘 거짓이 되고 아주 작은 수에서는 너무 커서 다른 값을 같다고 한다. 값의 크기에 비례하는 상대 오차로 비교하는 편이 안전하다(The Floating-Point Guide). Unity가 == 대신 Mathf.Approximately를 따로 두는 것도 같은 이유다.
GPU와 대부분의 엔진이 쓰는 32비트 float는 유효 숫자가 24비트(십진 약 7자리)라, 이웃한 두 값의 간격이 수의 크기에 비례해 벌어진다. 원점에서 1m 떨어진 곳의 간격은 약 0.000119mm지만, 1,000m에서는 0.061mm, 100,000m에서는 7.8mm다. 2²⁴ = 16,777,216을 넘으면 정수조차 하나 건너 하나만 담긴다. 그래서 넓은 월드에서 원점에서 멀리 간 물체는 위치가 몇 mm 단위로 튀고 그 위의 물리도 거칠어진다. 정밀도는 원점에서의 거리로 정해지므로, 계산이 원점 가까이에서 일어나도록 좌표를 옮기는 것이 대책의 기본이다.
핵심 자료
렌더링 쪽 수학(투영, 셰이딩, 텍스처 좌표)은 컴퓨터 그래픽스 노트, 머신러닝 쪽 수학은 AI를 위한 수학에 있다.
| 자료 | 요지 | 링크 |
|---|---|---|
| Essential Mathematics for Games and Interactive Applications (Van Verth · Bishop) | 벡터·행렬·쿼터니언·보간·충돌·강체 물리·난수까지 게임 엔진에 필요한 수학을 한 권으로 다루는 교재. 누리집에 예제 코드와 강연 자료가 있다. | 책 누리집 |
| Red Blob Games (Amit Patel) | 격자, 경로 찾기, 노이즈, 곡선 같은 게임 수학을 직접 조작하는 그림으로 설명하는 글 모음. | Red Blob Games |
| Red Blob Games — Noise Functions and Map Generation | 1차원 노이즈부터 주파수·진폭을 바꿔 더하는 옥타브, 지형 생성까지 노이즈를 단계별로 쌓아 간다. | Red Blob Games |
| Inigo Quilez — 글 모음 | 거리 함수, 노이즈, 보간 함수, 셰이더 수학을 짧은 글과 코드로 정리한 자료. 그래픽 프로그래머들이 자주 찾아본다. | iquilezles.org |
| Gaffer on Games — Fix Your Timestep! | 가변 프레임 시간을 누적해 고정 간격으로 물리를 돌리고 남은 비율로 보간하는 루프를 설명한다. 고정 시간 간격의 표준 참고 글. | Gaffer on Games |
| Gaffer on Games — Integration Basics | 명시적 오일러, 반암시적 오일러, RK4를 용수철 예로 비교하며 적분기 선택이 물리의 안정성을 어떻게 바꾸는지 보여 준다. | Gaffer on Games |
| Ken Perlin — Improving Noise (2002) | 펄린 노이즈의 페이드 곡선을 6t⁵ − 15t⁴ + 10t³로 바꾸고 기울기 집합을 정리한 개정판 논문. | NYU |
| Visualizing quaternions (Grant Sanderson · Ben Eater) | 쿼터니언 곱과 회전을 4차원을 3차원으로 내려 보는 방식으로, 조작할 수 있는 해설 영상과 함께 보여 준다. | eater.net |
| The Floating-Point Guide — Comparison | ==가 왜 틀리는지, 고정 ε이 큰 수·작은 수에서 왜 무너지는지, 상대 오차로 비교하는 함수를 정리한 글. | floating-point-gui.de |
| Wikipedia — Single-precision floating-point format | float32의 유효 숫자 24비트(십진 약 7자리)와, 2²⁴을 넘으면 정수도 모두 담지 못한다는 점. | Wikipedia |
| Unity 문서 — Mathf.Approximately | 부동소수점을 ==로 비교하면 부정확하다는 설명과 작은 오차 안에서 같다고 보는 비교 함수. | Unity |
| Unity 문서 — Vector3.normalized | 정규화할 수 없을 만큼 작은 벡터에는 영벡터를 돌려준다는 약속. | Unity |
- Essential Mathematics for Games and Interactive Applications (Van Verth · Bishop)
- Red Blob Games (Amit Patel)
- Red Blob Games — Noise Functions and Map Generation
- Inigo Quilez — 글 모음
- Gaffer on Games — Fix Your Timestep!
- Gaffer on Games — Integration Basics
- Ken Perlin — Improving Noise (2002)
- Visualizing quaternions (Grant Sanderson · Ben Eater)
- The Floating-Point Guide — Comparison
- Wikipedia — Single-precision floating-point format
- Unity 문서 — Mathf.Approximately
- Unity 문서 — Vector3.normalized