← 개념 · 데모 가이드 | 구현 허브 | 1장. 기초 수학 | 삼각함수
IMPLEMENTATION · CHAPTER 1

삼각함수 — 한 줄 한 줄 직접 구현

회전하는 물체, 태양을 공전하는 행성, 흔들리는 카메라 — 그래픽스에서 "돈다"는 움직임은 거의 전부 sincos 두 함수 위에서 굴러갑니다. 이 페이지에서는 먼저 도(degree)와 라디안(radian)의 관계를 유도해 변환 함수를 만들고, 그다음 sin/cos만으로 2D 점을 원점 기준으로 회전시키는 rotate2D와, 물체를 원 궤도 위에 올려 공전시키는 orbitPosition을 한 성분씩 유도합니다. 마지막으로 deltaTime 기반 프레임 독립 애니메이션DirectXMath 대응까지 이어갑니다.

규약 이 가이드는 D3D 규약을 따릅니다: 행벡터 v' = v * M, 행렬은 행 우선 저장, 왼손 좌표계. 아래 rotate2D는 행렬을 쓰지 않는 순수 성분 계산이라 손계산으로 회전 공식을 눈으로 확인하기에 좋습니다. 행렬 회전(rotateZ 등)은 좌표계와 변환 페이지를 참고하세요.

0. 무엇을 만드나

이 페이지의 목표 산출물은 딱 네 개입니다. 각각이 그래픽스 어디에 쓰이는지 먼저 감을 잡고 들어갑시다.

1. 도(degree) vs 라디안(radian): 왜 라디안인가

라디안이라는 게 대체 무엇인가

우리가 일상에서 쓰는 도(°)는 한 바퀴를 360 등분한 단위입니다. 360이라는 숫자는 수학적 필연이 아니라 고대 바빌로니아의 60진법에서 온 역사적 관습일 뿐입니다. 반면 라디안은 원 자체의 기하학에서 자연스럽게 나옵니다.

정의(그림글): 반지름이 r인 원을 그리고, 그 원 위에서 호(둘레의 일부)의 길이가 정확히 r만큼 되는 부채꼴을 잘라냅니다. 그 부채꼴의 중심각이 바로 1 라디안입니다. 즉 "각도"를 "호의 길이 ÷ 반지름"으로 정의한 것입니다(θ = 호길이 / r). 이렇게 하면 각도가 길이의 비라서 단위가 없어지고, 원의 크기와 무관해집니다.

원의 전체 둘레는 2πr입니다. 호 길이가 2πr이면 각도는 2πr / r = 2π 라디안, 즉 한 바퀴가 2π 라디안 = 360°입니다. 여기서 핵심 변환 관계가 나옵니다:

공식 2π 라디안 = 360°  ⟹  π 라디안 = 180°  ⟹  1° = π/180 라디안,  1 라디안 = 180/π ° ≈ 57.2958°.

왜 하필 라디안을 써야 하나 (세 가지 이유)

  1. 표준 라이브러리가 라디안만 받는다. C의 <cmath>에 있는 std::sin, std::cos, std::tan인자를 라디안으로 해석합니다. 여기에 도 단위 값(예: 90)을 그대로 넣으면 "90 라디안"(약 5156°, 14바퀴하고도 조금)을 계산해 완전히 엉뚱한 값이 나옵니다.
  2. 미적분·물리 공식이 라디안 위에서만 깔끔하다. 예를 들어 각이 작을 때 sin(θ) ≈ θ라는 근사, 회전 속도(각속도) ω와 실제 이동 속도 v = ωr 같은 관계식이 라디안일 때만 계수 없이 성립합니다.
  3. DirectXMath 등 그래픽 수학 라이브러리도 라디안 기준. XMMatrixRotationZ(angle), XMMatrixPerspectiveFovLH(fovAngleY, ...) 등은 모두 라디안을 요구합니다. 그래서 사람이 도로 입력하고 싶을 때는 XMConvertToRadians(90.0f)로 바꿔서 넘겨야 합니다.

정리하면 "사람은 도로 생각하고, 컴퓨터는 라디안으로 계산한다." 그 경계에 변환 함수를 놓는 것이 이 절의 목적입니다.

2. 변환 함수: toRadians / toDegrees

Trig.hcpp
#include "Vec3.h"
#include <cmath>

constexpr float PI = 3.14159265358979323846f;

inline float toRadians(float deg){ return deg * (PI / 180.f); }
inline float toDegrees(float rad){ return rad * (180.f / PI); }

시그니처 한 조각씩 뜯어보기

constexpr float PIconstexpr는 "이 값은 컴파일 시점에 이미 확정된 상수"라는 뜻입니다. 런타임에 계산되지 않으므로 어디서 써도 추가 비용이 없고, 배열 크기 같은 상수식 자리에도 넣을 수 있습니다. 뒤에 붙은 f는 이 숫자가 double이 아니라 float(단정밀도)라는 표시입니다. 그래픽스는 대부분 float로 계산하므로, f를 빼면 double↔float 변환이 섞여 경고나 미세한 정밀도 손실이 납니다.

inline — 이 함수가 여러 .cpp에서 #include되어도 중복 정의 링크 오류가 나지 않게 하고, 컴파일러에게 "호출을 함수 본문으로 펼쳐 넣어도 좋다"는 힌트를 줍니다. 본문이 곱셈 한 번뿐인 이런 초소형 함수에 딱 맞습니다.

float toRadians(float deg) — 도 값을 받아 라디안을 값으로 반환합니다. float는 4바이트로 작아서 참조(const float&)로 받을 이유가 없습니다. 오히려 복사가 더 쌉니다. (참조가 이득인 경우는 Vec3처럼 큰 구조체이고, 그건 다음 함수에서 다룹니다.)

수식 → 직관 → 코드 3단 매핑

수학적 의미: 1절에서 얻은 1° = π/180 라디안을 그대로 옮긴 것입니다. deg도가 있으면 라디안은 deg × (π/180).

기하학적 직관: π/180은 "1도가 원둘레(2π) 중 차지하는 비율"입니다. 한 바퀴 360도를 2π 라디안이라는 파이 조각으로 나눌 때, 도 하나당 파이 조각의 크기가 2π/360 = π/180인 셈이죠. 그 조각 크기에 도 개수를 곱하면 총 라디안이 됩니다.

코드 매핑: deg * (PI / 180.f)에서 괄호로 PI / 180.f를 묶은 것은 "1도당 라디안"이라는 상수 계수를 먼저 만들고 거기에 도를 곱한다는 의도를 드러냅니다. 값 자체는 (deg * PI) / 180.f와 같지만, 의미가 더 잘 읽히고 PI/180.f는 컴파일 시 상수로 접혀 계산됩니다. toDegrees는 정확히 역연산으로, 180/π ≈ 57.2958을 곱해 라디안을 도로 되돌립니다.

손계산으로 확인

함정 std::sin(90)은 재앙입니다. 90을 라디안으로 해석해 sin(90 rad) ≈ 0.894를 돌려줍니다. 의도한 sin(90°) = 1과 전혀 다르죠. 반드시 std::sin(toRadians(90))처럼 라디안으로 바꾼 뒤 넘기세요. "각도를 함수에 넣기 직전에는 항상 라디안인지 의심한다"를 습관으로 만드세요.

3. 2D 회전: rotate2D

무엇을 만드나: 2D 점 (x, y)원점(0,0) 기준으로 반시계 방향 angle(라디안)만큼 돌린 새 좌표로 갱신합니다. 왜 필요한가: 스프라이트를 회전시키거나, 방향 벡터를 특정 각만큼 틀거나, 2D 게임에서 총알을 조준 방향으로 쏘는 등 "평면에서 돌리기"의 가장 기본 도구입니다.

  1. 회전각의 sincos한 번씩만 미리 계산해 둔다.
  2. 표준 2D 회전 공식으로 새 x새 y를 각각 계산한다.
  3. 계산이 모두 끝난 뒤 임시 변수의 값을 원래 변수에 되쓴다(제자리 갱신 버그 방지).
Trig.hcpp
// 2D 점 (x,y)를 원점 기준 angle(라디안)만큼 회전
inline void rotate2D(float& x, float& y, float angle){
    float s = std::sin(angle), c = std::cos(angle);
    float nx = x*c - y*s;   // 표준 2D 회전 공식 (새 x)
    float ny = x*s + y*c;   //                    (새 y)
    x = nx; y = ny;         // 계산이 끝난 뒤 한꺼번에 되쓴다
}

시그니처: 왜 float&(참조)이고 반환은 void인가

float& x, float& y&참조(reference) 표시입니다. 참조로 받으면 함수 안에서 x, y를 바꾸는 것이 호출한 쪽의 원본 변수를 직접 바꾸는 것이 됩니다. 그래서 이 함수는 값을 돌려줄 필요가 없어 반환 타입이 void입니다. 즉 "들어온 점을 제자리에서 회전시킨다"는 설계입니다. 만약 참조 없이 float x로 받았다면 복사본만 회전시키고 원본은 그대로 남아 아무 효과가 없었을 것입니다.

(참고: Vec3를 받아 회전한 새 Vec3반환하는 스타일도 흔합니다. 여기서는 x/y 두 성분만 다루므로 참조 인자로 in-place 갱신하는 편이 간결합니다.)

회전 공식은 어디서 오나 — 단위원으로 유도

1단계, 극형식으로 보기(그림글):(x, y)를 원점에서 뻗은 화살표라고 생각합시다. 이 화살표는 길이 r과, x축에서 잰 각 φ로도 표현할 수 있습니다. 삼각함수 정의에 의해 x = r·cos φ, y = r·sin φ입니다. 즉 화살표 끝은 반지름 r인 원 위에 있고, 그 원 위 위치를 각 φ가 정합니다.

2단계, angle만큼 더 돌리기: 이 화살표를 반시계로 angle(=θ)만큼 더 돌리면 새 각은 φ + θ가 됩니다. 길이 r은 그대로입니다(회전은 길이를 바꾸지 않으니까). 그러므로 새 좌표는:

유도 new_x = r·cos(φ + θ),   new_y = r·sin(φ + θ).

3단계, 덧셈정리 적용: 고등학교 삼각함수의 합각 공식을 씁니다.

양변에 r을 곱하고, r·cos φ = x, r·sin φ = y를 도로 대입하면 r과 φ가 모두 사라지고 원래 좌표 x, y와 회전각 θ만으로 깔끔하게 정리됩니다:

결과 new_x = x·cos θ − y·sin θ  →  코드의 nx = x*c - y*s
new_y = x·sin θ + y·cos θ  →  코드의 ny = x*s + y*c

이것이 코드의 두 줄이 나온 곳입니다. c = cos θ, s = sin θ로 이름만 줄였을 뿐 유도한 공식 그대로입니다.

부호와 순서: 왜 new_x에는 −y·s이고 new_y에는 +x·s인가

두 식에서 sin 항의 부호가 서로 반대(−y·s vs +x·s)인 것이 핵심입니다. 이 비대칭이 바로 "회전"을 만듭니다. 만약 두 식 모두 같은 부호라면 그것은 회전이 아니라 다른 변형(예: 늘리기/기울이기)이 됩니다. 기하학적 확인(그림글):(1, 0)(x축 위)을 90°(θ=π/2, 즉 c=0, s=1) 돌려 봅시다.

결과는 (0, 1) — x축 위의 점이 반시계로 돌아 y축(위쪽)으로 갔습니다. 우리가 기대한 반시계 회전과 정확히 일치하죠. 부호가 반대로 붙어 있었다면 시계 방향(아래쪽)으로 갔을 것입니다. 주의: 화면 좌표처럼 y축이 아래로 향하는 좌표계에서는 같은 공식이 시각적으로는 시계 방향처럼 보입니다 — 부호가 틀린 게 아니라 y축 방향이 뒤집혀 있기 때문입니다.

nx, ny 임시 변수에 담는가 — 제자리 갱신 버그

이 부분이 초보자가 가장 많이 틀리는 지점입니다. 만약 임시 변수 없이 이렇게 쓰면:

버그 예시 (이렇게 쓰지 마세요)cpp
x = x*c - y*s;   // ① 여기서 x가 '새 x'로 바뀜
y = x*s + y*c;   // ② 그런데 이 줄의 x는 이미 바뀐 값!  ← 버그

①번 줄이 실행되는 순간 x는 즉시 새 값으로 덮어써집니다. 그런데 ②번 줄의 new_y = x·s + y·c 공식은 원래(회전 전) x를 써야 합니다. ②에서 읽히는 x는 이미 오염된 새 값이라, y가 엉뚱하게 계산됩니다. 이렇게 "아직 다른 계산에 쓰여야 할 값을 먼저 덮어써 버리는" 실수를 제자리 갱신(in-place update) 버그라고 합니다.

해결책은 코드처럼 모든 새 값을 임시 변수(nx, ny)에 먼저 다 계산한 뒤, 마지막에 한꺼번에 x = nx; y = ny;로 되쓰는 것입니다. 이 시점에는 nx, ny가 모두 원래 x, y로 계산되어 있으므로 안전합니다. 회전, 좌표 스왑, 셀룰러 오토마타 등 "한 값이 다른 값 계산에 얽혀 있는" 갱신에서는 항상 이 패턴을 씁니다.

성능 std::sinstd::cos는 덧셈·곱셈보다 훨씬 무거운 초월함수입니다. 그래서 같은 각도의 sin/cos는 한 번만 구해 s, c에 담아 두 식에서 재사용합니다. 만약 식 안에 std::sin(angle)을 네 번 그냥 써 버리면 같은 값을 네 번 계산해 낭비합니다. 참고로 sin과 cos를 동시에 구하는 전용 API도 있습니다 (아래 DirectXMath 절의 XMScalarSinCos).

4. 공전 궤도: orbitPosition

무엇을 만드나: center를 중심으로 반지름 radius인 원 위에서, 각 angle에 해당하는 한 점의 3D 위치를 돌려줍니다. 왜 필요한가: 행성이 태양 주위를 도는 공전, 카메라가 물체 주위를 도는 궤도 카메라, 링 모양으로 파티클을 배치하는 것 등이 전부 "원 위의 좌표 구하기"입니다.

  1. 단위원 위의 좌표 (cos angle, sin angle)을 구한다(반지름 1인 원 위 점).
  2. 거기에 radius를 곱해 원하는 크기의 원으로 키운다.
  3. center를 더해 원의 중심을 원점에서 원하는 위치로 옮긴다.
Trig.hcpp
// center 중심, 반지름 radius, 각 angle 위치 (XZ 평면 공전)
inline Vec3 orbitPosition(const Vec3& center, float radius, float angle){
    return {
        center.x + radius * std::cos(angle),   // X 성분
        center.y,                              // Y 성분 (평면 유지, 고정)
        center.z + radius * std::sin(angle)    // Z 성분
    };
}

시그니처: const Vec3& center는 왜 참조이고 왜 const인가

const Vec3& center에서 &(참조)Vec3(float 3개 = 12바이트, 정렬 포함하면 더)라는 구조체를 통째로 복사하지 않고 원본을 가리키게 해서 호출 비용을 줄입니다. 앞의 toRadians(float)가 값으로 받은 것과 대조되죠 — float 하나는 복사가 싸지만 구조체는 참조가 이득입니다.

const는 "이 함수는 center읽기만 하고 절대 바꾸지 않는다"는 약속입니다. 세 가지 이점이 있습니다: (1) 실수로 원본을 수정하는 버그를 컴파일러가 막아 주고, (2) 함수를 읽는 사람에게 "입력을 건드리지 않는 순수 계산"임을 알려 주며, (3) const 객체나 임시값(예: orbitPosition(sunPos, ...)에서 sunPos가 const여도)도 인자로 넘길 수 있게 합니다.

반환 타입 Vec3값 반환입니다. 새 위치는 함수 안에서 새로 만들어지는 값이므로 참조로 돌려줄 수 없습니다 (사라질 지역 값의 참조를 돌려주면 위험). return { ... };집합 초기화(aggregate initialization)로, 중괄호 안 세 값을 순서대로 Vec3x, y, z에 채워 넣어 그 자리에서 새 Vec3를 만들어 반환합니다. 멤버 이름을 일일이 쓰지 않아 간결합니다.

cos/sin이 어떻게 원 위 좌표를 만드나 — 단위원 재방문

기하학적 직관(그림글): 반지름 1인 원(단위원)의 중심을 원점에 두고, x축에서 각 angle만큼 반시계로 돌린 지점을 찍으면 그 점의 좌표가 바로 (cos angle, sin angle)입니다. 이것이 sin/cos의 정의 그 자체입니다. angle이 0이면 (cos 0, sin 0) = (1, 0) — 오른쪽 끝. angle이 π/2면 (0, 1) — 위쪽 끝. angle이 π면 (−1, 0) — 왼쪽 끝. angle이 계속 커지면 점이 원을 따라 빙 돕니다.

반지름 키우기: 단위원 위 점에 radius를 곱하면 (radius·cos angle, radius·sin angle)이 되어 반지름 radius인 원 위 점이 됩니다. 원의 모양(각 위치)은 그대로고 크기만 커진 것이죠.

중심 옮기기: 여기에 center를 더하면 원의 중심이 원점에서 center 위치로 평행 이동합니다. 그래서 최종적으로 center + radius·(cos angle 방향)이 되어 "center를 중심으로 반지름 radius인 원 위 점"이 완성됩니다. 이 세 단계(단위원 → 크기 → 위치)는 다른 많은 절차적 배치 코드에도 그대로 재활용됩니다.

왜 XZ 평면인가 — X와 Z에 넣고 Y는 고정한 이유

코드를 보면 cosX에, sinZ에 들어가고, Ycenter.y 그대로 둡니다. 즉 원이 XZ 평면(수평 바닥면)에 그려집니다. 이유는 게임/3D 씬의 관례 때문입니다: 대부분의 3D 좌표계에서 Y축이 위(up)를 가리킵니다. 행성이 태양을 도는 궤도, 캐릭터가 원을 그리며 걷는 경로처럼 "바닥에 평행하게 도는" 움직임은 높이(Y)를 고정한 채 수평면 위에서 돌아야 자연스럽습니다. 그래서 회전 성분을 X와 Z에 배치하고 Y를 상수로 두는 것입니다.

응용 평면을 바꾸고 싶으면 성분만 갈아끼우면 됩니다. XY 평면(화면에 세로로 선 원, 2D 느낌): cos→X, sin→Y, Z 고정. YZ 평면(옆에서 본 세로 원): cos→Z, sin→Y, X 고정. 어느 두 축에 cos/sin을 넣느냐가 곧 회전 평면입니다.

손계산으로 확인

중심 (0,0,0), 반지름 8일 때:

5. 렌더 루프에서 공전시키기 (deltaTime 애니메이션)

main.cpp (렌더 루프 발췌)cpp
float angle = 0.f;
Vec3 sunPos{ 0, 0, 0 };

// --- 매 프레임 호출되는 루프 안에서 ---
angle += 1.0f * deltaTime;   // 초당 1라디안(약 57도/초)씩 증가
Vec3 planetPos = orbitPosition(sunPos, 8.f, angle);
// planetPos 위치에 행성 메시를 그린다...

deltaTime이란 무엇이고 왜 곱하나 — 프레임 독립성

deltaTime직전 프레임을 그린 뒤 이번 프레임까지 걸린 시간(초)입니다. 예를 들어 60FPS로 돌아가면 한 프레임에 약 1/60 ≈ 0.0167초가 걸리므로 deltaTime ≈ 0.0167입니다. 30FPS라면 ≈ 0.0333이 됩니다.

왜 그냥 angle += 1.0f로 하면 안 되나? 그렇게 하면 매 프레임 각이 1라디안씩 늘어납니다. 그런데 프레임 수는 컴퓨터 성능에 따라 다릅니다. 빠른 PC(120FPS)는 1초에 120번 더해 초당 120라디안 회전하고, 느린 PC(30FPS)는 초당 30라디안만 회전합니다. 같은 게임인데 컴퓨터마다 회전 속도가 달라지는 심각한 문제죠.

deltaTime을 곱하면 해결됩니다. "초당 얼마"라는 속도에 "이번 프레임이 걸린 시간"을 곱하면 이번 프레임에 실제로 진행해야 할 양이 나옵니다(거리 = 속도 × 시간). 1.0f * deltaTime은 "초당 1라디안"이라는 속도입니다. 120FPS에서는 한 프레임에 1 × 0.0083 = 0.0083라디안씩 120번 = 초당 1라디안, 30FPS에서는 1 × 0.0333 = 0.0333라디안씩 30번 = 역시 초당 1라디안. 프레임레이트가 달라도 결과 속도는 같습니다. 이것을 프레임 독립(frame-rate independent) 애니메이션이라고 합니다.

회전 속도를 바꾸고 싶으면 계수만 조절하면 됩니다: angle += 2.0f * deltaTime이면 초당 2라디안(약 114도/초, 두 배 빠름), angle += toRadians(90.f) * deltaTime이면 "초당 정확히 90도"로 사람이 이해하기 쉬운 속도가 됩니다.

성능 angle은 계속 커지므로 아주 오래 돌면 float 정밀도가 떨어질 수 있습니다. 필요하면 를 넘을 때 빼서 [0, 2π) 범위로 감아 주면(if(angle > 2*PI) angle -= 2*PI;) 정밀도와 값 범위를 안정적으로 유지할 수 있습니다. sin/cos는 주기라 각을 감아도 결과는 동일합니다.

6. DirectXMath 대응

학습용으로 직접 구현해 원리를 확인했으니, 실무에서는 SIMD로 최적화된 DirectXMath를 씁니다. 위에서 만든 것들이 각각 무엇에 대응하는지 1:1로 맞춰 봅시다.

DirectXMath 버전cpp
#include <DirectXMath.h>
using namespace DirectX;

// 1) 도→라디안: toRadians 대응
float rad = XMConvertToRadians(90.f);        // = π/2
float deg = XMConvertToDegrees(XM_PIDIV2);   // toDegrees 대응 → 90

// 2) 2D 회전: rotate2D 대응 (Z축 회전 행렬로 (x,y) 평면을 돌림)
XMVECTOR p = XMVectorSet(x, y, 0.f, 1.f);
XMMATRIX R = XMMatrixRotationZ(angle);       // 라디안!
p = XMVector3Transform(p, R);                // 행벡터 규약: v' = v * R
float nx = XMVectorGetX(p), ny = XMVectorGetY(p);

// sin/cos를 한 번에 (rotate2D의 s,c 재사용에 대응)
float s, c;
XMScalarSinCos(&s, &c, angle);               // s=sin, c=cos 동시 계산

// 3) 공전: orbitPosition 대응 (XZ 평면)
XMVECTOR center = XMVectorSet(0, 0, 0, 1);
XMVECTOR planet = XMVectorAdd(
    center,
    XMVectorSet(radius * c, 0.f, radius * s, 0.f));

1:1 대응 표

직접 구현DirectXMath대응 설명
PIXM_PI, XM_PIDIV2, XM_2PI미리 정의된 π 상수들(π, π/2, 2π).
toRadians(deg)XMConvertToRadians(deg)deg * (π/180)과 동일.
toDegrees(rad)XMConvertToDegrees(rad)rad * (180/π)과 동일.
s=sin, c=cos 개별XMScalarSinCos(&s,&c,angle)sin/cos를 한 번에 계산해 재사용(성능).
rotate2D(x,y,angle)XMMatrixRotationZ(angle) + XMVector3TransformZ축 회전은 XY 평면을 돌리는 것 = 우리의 2D 회전과 동일한 c/-s/s/c 성분.
공전 좌표 조립XMVectorSet + XMVectorAddcenter + (radius·cos, 0, radius·sin) 벡터 덧셈.
함정 DirectXMath의 회전/투영 함수도 전부 라디안입니다. XMMatrixRotationZ(90.f)처럼 도를 그냥 넣으면 "90라디안 회전"이라는 엉뚱한 결과가 나옵니다. 반드시 XMMatrixRotationZ(XMConvertToRadians(90.f))로 감싸세요. 또 이 가이드는 행벡터 규약(v' = v * R)이라 셰이더에서는 mul(v, R)이고, cbuffer로 올릴 때는 XMMatrixTranspose가 필요하다는 점도 잊지 마세요.