← 개념 · 데모 가이드 | 구현 허브 | 1장. 기초 수학 | 내적 · 외적
IMPLEMENTATION · CHAPTER 1

내적 · 외적의 실전 활용 — 한 줄 한 줄 직접 구현

벡터 페이지에서 dotcross가 무엇인지 성분까지 뜯어봤습니다. 이제 그 두 연산이 실제 그래픽스 코드에서 어디에 쓰이는지 봅니다. 사잇각(빛과 표면의 각), 투영(그림자·미끄러짐), 삼각형 법선(면이 향하는 방향), 2D 감김 부호(앞뒤 판정). 네 개의 작은 함수가 전부지만, 그 안에 렌더링 파이프라인 절반의 직관이 들어 있습니다. 이 페이지는 그 기하학적 의미를 코드 한 줄, 부호 하나까지 그림글로 풀어냅니다.

규약 D3D 규약으로 통일합니다: 왼손 좌표계, 외적은 왼손 법칙으로 방향이 정해지고, 삼각형 앞면(front face)은 시계 방향(CW) 감김이 기본입니다. OpenGL(오른손·CCW 앞면)과 부호·방향이 반대이니 다른 자료의 정점 순서를 그대로 옮기지 마세요.
전제 아래 코드는 모두 Vec3.h에서 만든 Vec3와 그 연산 (operator+ - *, dot, cross, length, normalize)을 #include해서 씁니다. 이 페이지에서는 그 재료를 조합만 합니다.

먼저: 내적과 외적, 한 문장씩 직관

본격적으로 함수를 만들기 전에, 이 페이지 전체를 관통하는 두 연산의 기하 직관부터 확실히 잡고 갑니다. 공식은 벡터 페이지에서 봤으니, 여기서는 "그래서 이게 뭘 뜻하는가"에 집중합니다.

내적(dot) — "두 벡터가 얼마나 같은 방향인가"

내적 a·b = ax*bx + ay*by + az*bz는 하나의 숫자(스칼라)를 냅니다. 이 숫자의 부호가 핵심입니다. 두 벡터를 화살표로 그렸을 때:

더 정확히는 a·b = |a| |b| cosθ입니다. 여기서 θ가 두 벡터 사이의 각이고, |a|·|b|는 각 벡터의 길이입니다. cos는 각이 0°일 때 1(최대), 90°일 때 0, 180°일 때 −1(최소)이므로, 위의 부호 규칙이 그대로 나옵니다. "빛 벡터와 표면 법선의 내적"이 조명 계산의 심장인 이유가 바로 이것입니다 — 표면이 빛을 정면으로 받을수록(같은 방향에 가까울수록) 값이 커집니다.

외적(cross) — "두 벡터 모두에 수직인 새 벡터"

외적 a×b는 벡터를 냅니다(내적은 숫자, 외적은 벡터 — 헷갈리기 쉬우니 꼭 구분). 이 결과 벡터는 a와 b가 만드는 평면에 수직입니다. 즉 삼각형이나 사각형 같은 면을 두 변으로 표현한 뒤 외적을 하면, "이 면이 어느 쪽을 향하는가"를 나타내는 법선(normal)이 바로 나옵니다.

방향은 어느 쪽 수직일까요(위 아니면 아래)? 왼손 법칙으로 정해집니다: 왼손 손가락을 첫 벡터 a에서 둘째 벡터 b 쪽으로 감아쥐면, 엄지가 가리키는 방향이 a×b입니다. 그래서 순서가 바뀌면 방향이 뒤집힙니다: b×a = −(a×b). 이 "순서 → 방향" 관계가 바로 뒤에서 삼각형 정점 순서(감김)가 앞뒷면을 결정하는 원리로 이어집니다.

외적 결과의 길이도 의미가 있습니다: |a×b| = |a| |b| sinθ이고, 이것은 두 벡터가 만드는 평행사변형의 넓이와 같습니다. 삼각형 넓이는 그 절반이죠. 2D 감김 함수의 부호가 여기서 나옵니다.

1) 두 벡터의 사잇각 — angleBetween

무엇을 만드나: 두 방향 벡터 사이의 각을 라디안으로 돌려주는 함수입니다. 왜 필요한가: "카메라가 목표를 얼마나 정면으로 보고 있나", "관절이 몇 도 꺾였나", "시야각(FOV) 안에 들어오나" 같은 판정에 씁니다. 내적으로 cosθ를 구한 뒤 acos로 각을 되돌립니다.

  1. 두 벡터의 길이를 곱해 |a||b|(분모)를 만든다. 0이면(길이 0 벡터) 계산 불가라 조기 반환.
  2. a·b(내적)를 분모로 나눠 cosθ를 얻는다.
  3. 부동소수 오차로 −1~1을 벗어날 수 있으니 clamp으로 강제로 가둔다.
  4. acos(코사인의 역함수)로 cosθθ(라디안)를 되돌린다.
DotCross.h — angleBetweencpp
#include "Vec3.h"      // 벡터 페이지에서 만든 Vec3, dot, cross, length, normalize
#include <cmath>       // std::acos
#include <algorithm>   // std::clamp

// 두 벡터의 사잇각(라디안). cosθ = (a·b) / (|a||b|)
inline float angleBetween(const Vec3& a, const Vec3& b)
{
    float denom = length(a) * length(b);   // |a| * |b|
    if (denom < 1e-8f) return 0.0f;        // 길이 0 벡터 방어(0으로 나누기 금지)
    float c = dot(a, b) / denom;           // c = cosθ
    c = std::clamp(c, -1.0f, 1.0f);        // 부동소수 오차로 |c|>1 되는 것 방지
    return std::acos(c);                    // θ = arccos(cosθ)
}

시그니처 해부

inline float angleBetween(const Vec3& a, const Vec3& b)

cosθ = (a·b) / (|a||b|) — 왜 이 공식인가 (수학 → 기하 → 코드)

수학적 의미: 내적의 정의 자체가 a·b = |a| |b| cosθ입니다. 우리가 원하는 건 θ이므로, 양변을 |a||b|로 나누면 cosθ = (a·b)/(|a||b|)가 됩니다. 이게 코드의 세 번째 줄입니다.

기하학적 직관: 왜 길이로 나눌까요? 내적 값 자체는 벡터가 길수록 커집니다. 같은 각도라도 벡터를 두 배 길게 하면 내적도 두 배가 되죠. 각도는 길이와 무관해야 하므로, 길이의 영향을 나눗셈으로 제거하는 것입니다. 그러면 순수하게 "방향이 얼마나 일치하는가"만 남고, 그 값이 정확히 cosθ(−1~1 사이)가 됩니다. 이 나눗셈은 사실 양쪽 벡터를 각각 정규화(길이 1로)한 뒤 내적한 것과 같습니다.

코드 매핑: denom = length(a)*length(b)|a||b|, dot(a,b)/denom(a·b)/(|a||b|), 즉 c = cosθ. 마지막 acos(c)cosθ → θ. acos는 코사인의 역함수라 0~π(0~180°) 범위의 각을 돌려줍니다 — 방향의 부호 없는 사잇각이라 항상 0 이상입니다.

denom < 1e-8f 조기 반환인가

분모가 0이면 0/0 = NaN(숫자 아님)이 나와 이후 계산이 전부 오염됩니다. 길이 0 벡터(원점 방향 없음)는 애초에 "각도"라는 개념이 없으므로, 아주 작은 임계값 1e-8(= 0.00000001) 아래면 안전하게 0을 돌려줍니다. float는 정확히 0인지 비교하기보다 작은 엡실론과 비교하는 게 안전합니다(부동소수는 정확히 0이 아닐 수 있음).

함정 clamp을 빼면 NaN이 터집니다. 이론상 cosθ는 −1~1이지만, 부동소수 계산에서 dot(a,b)/denom1.0000001처럼 아주 살짝 1을 넘을 수 있습니다. acos는 정의역이 [−1, 1]이라 그 밖의 값을 넣으면 NaN을 반환하고, 그 NaN이 각도·회전·행렬로 전파돼 화면이 검게 되거나 물체가 사라집니다. std::clamp(c, -1.f, 1.f)는 이 한 줄로 그 재앙을 원천 차단합니다 — 절대 생략하지 마세요.
성능 "앞을 보고 있나?" 판정엔 acos가 필요 없습니다. acos·sqrt는 비싼 초월함수라 픽셀·정점마다 부르면 부담됩니다. 방향만 알면 될 때는 정규화된 두 벡터의 내적 부호만 보세요: dot > 0이면 같은 방향(예각), < 0이면 반대(둔각), ≈ 0이면 직각. 각도의 정확한 값이 아니라 비교만 필요할 때는 cos 상태로 두고 비교하는 게 정석입니다(예: FOV 판정도 cos(FOV/2)와 내적을 직접 비교).

손계산 예제

a = (1, 0, 0), b = (1, 1, 0)이라고 합시다.

x축과 "x·y 대각선"의 각이 45°. 그림으로 그려봐도 딱 맞습니다.

DirectXMath 대응

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

float angleBetweenXM(FXMVECTOR a, FXMVECTOR b)
{
    // XMVector3AngleBetweenVectors가 내부에서 clamp + acos까지 다 해줍니다.
    XMVECTOR ang = XMVector3AngleBetweenVectors(a, b);
    return XMVectorGetX(ang); // 결과는 라디안, 모든 레인에 동일하게 채워져 옴
}
원본DirectXMath대응
dot(a,b)XMVector3Dot내적(스칼라를 벡터 4레인에 복제)
length(a)XMVector3Length벡터 길이
clamp + acosXMVector3AngleBetweenVectors사잇각 전체를 한 번에(클램프 내장)

실무에서는 XMVector3AngleBetweenVectors 한 줄이면 됩니다(clamp까지 내장). 직접 구현은 "그 한 줄 안에서 무슨 일이 일어나는지" 알기 위한 학습용입니다.

2) 한 벡터를 다른 벡터에 투영 — projectOnto

무엇을 만드나: 벡터 a가 벡터 b 방향으로 "드리우는 그림자" 벡터를 돌려줍니다. 왜 필요한가: 속도를 경사면 방향/수직 성분으로 분해(미끄러짐), 카메라 이동을 바닥 평면에 눕히기, 어떤 방향으로의 이동량만 뽑아내기 등 벡터 분해가 필요한 곳이면 어디든 씁니다.

  1. b·b(= |b|²)를 구한다. 0이면(b가 길이 0) 투영할 방향이 없으니 영벡터 반환.
  2. 스칼라 계수 (a·b)/(b·b)를 구한다 — 이것이 "b를 몇 배 해야 하는가".
  3. 그 계수를 b에 곱해 b 방향의 벡터로 되돌린다.
DotCross.h — projectOntocpp
// a를 b 방향으로 투영. proj = (a·b / b·b) * b
inline Vec3 projectOnto(const Vec3& a, const Vec3& b)
{
    float bb = dot(b, b);                  // b·b = |b|²
    if (bb < 1e-8f) return {0, 0, 0};      // b가 영벡터면 방향 없음 → 영벡터
    return b * (dot(a, b) / bb);           // (a·b / b·b) * b
}

반환 타입이 Vec3인 이유, {0,0,0}이 무엇인지

사잇각과 달리 결과가 벡터(방향과 크기를 가진 화살표)라서 반환 타입이 Vec3입니다. return {0, 0, 0};집합 초기화(aggregate initialization)로, 중괄호 안의 값으로 Vec3{x=0, y=0, z=0}를 즉석에서 만들어 돌려주는 문법입니다. 반환 타입이 Vec3임을 컴파일러가 아니까 타입 이름을 다시 쓸 필요 없이 {0,0,0}만으로 영벡터가 됩니다. 두 인자 모두 const Vec3&인 이유는 앞과 동일합니다(복사 회피 + 불변 약속).

(a·b / b·b) * b가 왜 "투영"인가 (수학 → 기하 → 코드)

기하학적 직관부터: b가 바닥에 그어진 직선(레일)이라고 상상하세요. a라는 화살표 끝에서 그 레일로 수직으로 빛을 내리쬐어 생기는 그림자가 투영 벡터입니다. 그림자는 반드시 레일(b) 위에 있으므로, 결과는 b의 상수배 형태 k*b일 수밖에 없습니다. 남은 문제는 "그 k가 얼마냐"뿐입니다.

수학적으로 k 구하기: 투영은 a의 성분 중 b 방향만 남긴 것입니다. b 방향의 단위벡터는 b/|b|이고, a가 그 방향으로 가진 길이(스칼라 투영)는 내적 a·(b/|b|) = (a·b)/|b|입니다. 이 길이만큼 단위벡터를 곱하면 벡터 투영이죠:

proj = (a·b / |b|) × (b / |b|) = (a·b / |b|²) × b = (a·b / b·b) × b

마지막에서 |b|² = b·b를 썼습니다(길이 제곱 = 자기 자신과의 내적). 그래서 코드가 b * (dot(a,b) / dot(b,b))가 됩니다. 제곱근(sqrt)이 등장하지 않는 것이 이 공식의 묘미입니다 — |b|를 구하려면 sqrt가 필요하지만, |b|²로 묶어버려 b·b만 쓰므로 더 싸고 정확합니다.

부호와 크기의 의미

계수 (a·b)/(b·b)부호는 곧 a·b의 부호입니다(분모 b·b는 항상 양수). 즉 ab와 같은 쪽을 보면 투영이 b와 같은 방향, 반대쪽을 보면 b와 반대 방향으로 나옵니다. 크기는 ab 방향으로 얼마나 "멀리 가는가"에 비례합니다.

활용 투영을 알면 수직 성분도 공짜로 얻습니다: perp = a - projectOnto(a, b). 이 perpb에 정확히 수직인 성분입니다. 경사면에서 중력을 "면을 따라 미끄러지는 힘(수직 성분)"과 "면을 누르는 힘(투영 성분)"으로 나눌 때 이 한 쌍을 그대로 씁니다.

손계산 예제

a = (3, 4, 0)b = (1, 0, 0)(x축)에 투영해 봅시다.

(3,4,0)의 x축 그림자는 (3,0,0) — x 성분만 남고 y는 사라졌습니다. 직관과 정확히 일치. 수직 성분은 a - proj = (0,4,0)이 됩니다.

DirectXMath 대응

원본DirectXMath대응
수동 투영 공식XMVector3ClampLength는 아님 → 직접 계산투영 전용 함수는 없어 Dot로 조립
dot(a,b), dot(b,b)XMVector3Dot내적 두 번
b * scalarXMVectorScale(b, k)벡터에 스칼라 곱
DirectXMath 버전cpp
XMVECTOR projectOntoXM(FXMVECTOR a, FXMVECTOR b)
{
    XMVECTOR ab = XMVector3Dot(a, b);   // (a·b)를 4레인에 복제
    XMVECTOR bb = XMVector3Dot(b, b);   // (b·b)를 4레인에 복제
    XMVECTOR k  = XMVectorDivide(ab, bb); // 레인별 나눗셈 = a·b / b·b
    return XMVectorMultiply(k, b);        // k * b (레인별 곱)
}

3) 삼각형 법선 — triangleNormal

무엇을 만드나: 세 정점으로 이뤄진 삼각형이 어느 쪽을 향하는지를 가리키는 단위 법선 벡터를 돌려줍니다. 왜 필요한가: 조명(빛과 법선의 내적으로 밝기 결정), 앞뒷면 판정(back-face culling), 반사 계산 — 표면이 향하는 방향이 필요한 모든 곳의 출발점입니다.

  1. 한 정점 p0를 기준으로 두 변 벡터 e1 = p1 − p0, e2 = p2 − p0를 만든다.
  2. 두 변의 외적 e1 × e2를 구한다 → 삼각형 평면에 수직인 벡터.
  3. 정규화해서 길이를 1로 맞춘다(법선은 방향만 중요, 크기는 1로 통일).
DotCross.h — triangleNormalcpp
// 삼각형 법선: 두 모서리의 외적을 정규화. 정점 순서가 법선 방향을 결정한다.
inline Vec3 triangleNormal(const Vec3& p0, const Vec3& p1, const Vec3& p2)
{
    Vec3 e1 = p1 - p0;                 // 모서리 1: p0 → p1
    Vec3 e2 = p2 - p0;                 // 모서리 2: p0 → p2
    return normalize(cross(e1, e2));   // 두 모서리에 수직 → 정규화
}

왜 정점이 아니라 "변 벡터"로 외적하나

외적은 두 벡터(방향)에 대한 연산이지 점(위치)에 대한 게 아닙니다. 삼각형의 세 점 자체는 "위치"라 그대로 외적하면 의미가 없습니다. 그래서 한 점을 기준으로 뺄셈해서 e1 = p1 - p0, e2 = p2 - p0라는 두 모서리 방향을 만듭니다. 이 두 방향은 삼각형 평면 안에 놓여 있으므로, 둘 모두에 수직인 외적은 곧 평면에 수직 = 법선이 됩니다. (점 − 점 = 방향 벡터라는 규칙은 벡터 페이지에서 다뤘습니다.)

정점 순서(감김)와 법선 방향 — CCW / CW (수학 → 기하 → 규약)

기하학적 직관: 외적은 순서에 민감합니다(e1×e2 = −(e2×e1)). 따라서 삼각형을 이루는 정점을 도는 방향이 법선이 앞으로 나올지 뒤로 나올지를 결정합니다. 삼각형을 정면에서 볼 때 정점 p0→p1→p2가:

이건 왼손 법칙을 정점 순서에 적용한 결과입니다. 어느 쪽이 "앞면"인지는 API 규약이 정합니다.

D3D 규약 Direct3D 기본은 시계 방향(CW) 감김이 앞면(front face)입니다(왼손 좌표계). OpenGL 기본은 반대로 반시계(CCW)가 앞면입니다. 그래서 같은 모델 데이터를 두 API 사이에서 옮기면 안팎이 뒤집혀 보이거나 컬링이 반대로 걸릴 수 있습니다. 법선이 반대로 나오면 두 인자 순서를 바꾸거나(cross(e2, e1)) 결과에 -1을 곱하면 됩니다. 래스터라이저 상태(D3D11_RASTERIZER_DESC::FrontCounterClockwise)로 규약 자체를 바꿀 수도 있습니다.

normalize가 필요한가

외적의 길이|e1||e2|sinθ = 삼각형 평행사변형의 넓이라, 삼각형이 클수록 법선도 길어집니다. 하지만 법선은 방향만 의미 있고 크기는 필요 없습니다. 조명 공식(N·L)이 올바로 작동하려면 법선의 길이가 정확히 1이어야 하므로 normalize로 통일합니다.

함정 퇴화(degenerate) 삼각형에 주의하세요. 세 점이 한 직선 위에 있거나 두 점이 겹치면 e1e2가 평행이 되어 외적이 영벡터가 됩니다. 그러면 normalize가 0으로 나눠 NaN을 냅니다. 신뢰할 수 없는 메시 데이터를 다룬다면 cross 결과의 길이가 임계값보다 큰지 먼저 확인하세요.

손계산 예제

p0=(0,0,0), p1=(1,0,0), p2=(0,1,0) (xy 평면 위 삼각형).

xy 평면 삼각형의 법선이 +z(평면에 수직) — 정확합니다. 왼손 좌표계에서 +z는 화면 안쪽을 향하므로, 이 감김 순서는 D3D 기준으로 뒷면을 향하는 셈입니다. 앞면 법선을 원하면 p1p2를 맞바꾸면 (0,0,-1)이 나옵니다.

DirectXMath 대응

DirectXMath 버전cpp
XMVECTOR triangleNormalXM(FXMVECTOR p0, FXMVECTOR p1, FXMVECTOR p2)
{
    XMVECTOR e1 = XMVectorSubtract(p1, p0);   // p1 - p0
    XMVECTOR e2 = XMVectorSubtract(p2, p0);   // p2 - p0
    XMVECTOR n  = XMVector3Cross(e1, e2);      // 외적
    return XMVector3Normalize(n);              // 정규화
}
원본DirectXMath대응
p1 - p0XMVectorSubtract변 벡터 만들기(레인별 뺄셈)
cross(e1,e2)XMVector3Cross외적(왼손, 순서 동일)
normalize(...)XMVector3Normalize길이 1로

4) 2D 외적 부호 — cross2D

무엇을 만드나: 2D에서 두 벡터의 "외적"에 해당하는 스칼라 하나를 냅니다. 왜 필요한가: 이 값의 부호가 "점이 선의 어느 쪽에 있나", "삼각형이 앞/뒤로 감겼나(winding)", "세 점이 좌회전/우회전하나"를 즉시 알려줍니다. 2D 피킹·볼록 껍질·삼각형 채우기의 기본 원자 연산입니다.

DotCross.h — cross2Dcpp
// 2D 외적(스칼라). >0 이면 반시계(좌회전), <0 이면 시계(우회전), 0 이면 일직선.
inline float cross2D(float ax, float ay, float bx, float by)
{
    return ax * by - ay * bx;
}

왜 2D 외적은 벡터가 아니라 스칼라인가

3D 외적은 두 벡터에 수직인 벡터를 냅니다. 그런데 2D에서 두 벡터에 수직인 방향은 항상 화면 밖으로 나오는 z축 하나뿐입니다. 방향이 정해져 있으니 남는 정보는 그 z 성분의 값 하나 — 그래서 스칼라로 취급합니다. 실제로 ax*by - ay*bx는 3D 외적 (ax,ay,0) × (bx,by,0)z 성분과 정확히 같습니다. 삼각형 법선 손계산에서 봤던 z = e1.x*e2.y − e1.y*e2.x과 같은 꼴이죠.

부호가 뜻하는 것 (수학 → 기하)

이 스칼라는 |a||b|sinθ와 같습니다. sin은 각 θ가 0~180°(반시계로 돌 때)면 양수, 180~360°(=음의 각, 시계로 돌 때)면 음수입니다. 그래서:

크기 |cross2D|는 두 벡터가 만드는 평행사변형 넓이이고, 그 절반이 삼각형 넓이입니다. 부호까지 살리면 부호 있는 넓이(signed area)가 되어 감김 방향을 그대로 담습니다.

활용 점이 선분의 어느 쪽인가: 선분 방향 d = B - A, 점 방향 p = P - A일 때 cross2D(d.x, d.y, p.x, p.y)의 부호가 곧 좌/우입니다. 삼각형의 세 변에 대해 이걸 세 번 하면 "점이 삼각형 안에 있나"(세 부호가 모두 같은가)를 판정할 수 있습니다 — 소프트웨어 래스터라이저의 엣지 함수(edge function)가 바로 이것입니다.

손계산 예제

a = (1, 0)(오른쪽), b = (0, 1)(위쪽).

성능 cross2D는 곱셈 2번, 뺄셈 1번뿐 — sqrt·acos 없이 부호만으로 방향을 판정하므로 대량 반복(래스터라이저 픽셀 루프, 충돌 넓은 페이즈)에 이상적입니다. "각도가 몇 도인가"를 묻지 말고 "어느 쪽인가"만 물으면 이렇게 싸게 끝납니다.

정리 — 언제 무엇을 쓰나

질문도구핵심
두 방향이 같은 쪽? / 얼마나?내적 부호 / angleBetween부호만 필요하면 acos 생략
이 방향 성분만 뽑아내기projectOntosqrt 없이 b·b
면이 향하는 방향triangleNormal정점 순서가 앞뒤 결정, 정규화 필수
2D 좌/우, 감김, 점 안팎cross2D부호 = 방향, 크기 = 넓이×2

네 함수 모두 재료는 dotcross 둘뿐입니다. 내적은 "방향 일치도(스칼라)", 외적은 "수직 방향과 넓이(벡터/부호)" — 이 두 직관만 확실히 잡으면 조명·컬링·충돌·피킹의 코드가 전부 이 페이지의 조합으로 읽힙니다.