내적 · 외적의 실전 활용 — 한 줄 한 줄 직접 구현
벡터 페이지에서 dot과 cross가 무엇인지 성분까지 뜯어봤습니다.
이제 그 두 연산이 실제 그래픽스 코드에서 어디에 쓰이는지 봅니다. 사잇각(빛과 표면의 각),
투영(그림자·미끄러짐), 삼각형 법선(면이 향하는 방향), 2D 감김 부호(앞뒤 판정). 네 개의 작은 함수가
전부지만, 그 안에 렌더링 파이프라인 절반의 직관이 들어 있습니다. 이 페이지는 그 기하학적 의미를
코드 한 줄, 부호 하나까지 그림글로 풀어냅니다.
Vec3.h에서 만든 Vec3와 그 연산
(operator+ - *, dot, cross, length, normalize)을
#include해서 씁니다. 이 페이지에서는 그 재료를 조합만 합니다.
먼저: 내적과 외적, 한 문장씩 직관
본격적으로 함수를 만들기 전에, 이 페이지 전체를 관통하는 두 연산의 기하 직관부터 확실히 잡고 갑니다. 공식은 벡터 페이지에서 봤으니, 여기서는 "그래서 이게 뭘 뜻하는가"에 집중합니다.
내적(dot) — "두 벡터가 얼마나 같은 방향인가"
내적 a·b = ax*bx + ay*by + az*bz는 하나의 숫자(스칼라)를 냅니다. 이 숫자의 부호가
핵심입니다. 두 벡터를 화살표로 그렸을 때:
- a·b > 0 — 두 화살표가 대체로 같은 쪽을 봅니다(사잇각 90°보다 작음, 예각).
- a·b = 0 — 두 화살표가 정확히 직각입니다(수직).
- a·b < 0 — 두 화살표가 대체로 반대쪽을 봅니다(사잇각 90°보다 큼, 둔각).
더 정확히는 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로
각을 되돌립니다.
- 두 벡터의 길이를 곱해
|a||b|(분모)를 만든다. 0이면(길이 0 벡터) 계산 불가라 조기 반환. a·b(내적)를 분모로 나눠cosθ를 얻는다.- 부동소수 오차로 −1~1을 벗어날 수 있으니
clamp으로 강제로 가둔다. acos(코사인의 역함수)로cosθ→θ(라디안)를 되돌린다.
#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)
inline— 헤더에 함수 정의를 두면 여러.cpp에서#include할 때 "중복 정의" 링크 오류가 납니다.inline은 "여러 번 정의돼도 같은 것으로 합쳐라"라고 컴파일러에 허락하는 표시라 헤더-온리 함수에 붙입니다(성능상 인라인 확장은 부수 효과일 뿐).float(반환) — 각도 하나, 스칼라라서float. 단위는 라디안입니다 (도(°)가 아님 — 삼각함수 페이지에서 변환을 다룹니다).const Vec3& a—&는 참조로 받아Vec3구조체를 값 복사하지 않기 위함입니다(복사는 3개 float 낭비).const는 "이 함수는a를 절대 건드리지 않는다"는 약속이라, 호출부가 안심하고 넘길 수 있고 실수로 수정하면 컴파일 오류로 잡힙니다.
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이 아닐 수 있음).
cosθ는 −1~1이지만, 부동소수 계산에서
dot(a,b)/denom이 1.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)이라고 합시다.
a·b = 1*1 + 0*1 + 0*0 = 1|a| = √(1²+0²+0²) = 1,|b| = √(1²+1²+0²) = √2 ≈ 1.4142cosθ = 1 / (1 × 1.4142) = 0.7071θ = acos(0.7071) ≈ 0.7854 라디안 = 45°
x축과 "x·y 대각선"의 각이 45°. 그림으로 그려봐도 딱 맞습니다.
DirectXMath 대응
#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 + acos | XMVector3AngleBetweenVectors | 사잇각 전체를 한 번에(클램프 내장) |
실무에서는 XMVector3AngleBetweenVectors 한 줄이면 됩니다(clamp까지 내장). 직접 구현은
"그 한 줄 안에서 무슨 일이 일어나는지" 알기 위한 학습용입니다.
2) 한 벡터를 다른 벡터에 투영 — projectOnto
무엇을 만드나: 벡터 a가 벡터 b 방향으로 "드리우는 그림자"
벡터를 돌려줍니다. 왜 필요한가: 속도를 경사면 방향/수직 성분으로 분해(미끄러짐),
카메라 이동을 바닥 평면에 눕히기, 어떤 방향으로의 이동량만 뽑아내기 등 벡터 분해가 필요한
곳이면 어디든 씁니다.
b·b(=|b|²)를 구한다. 0이면(b가 길이 0) 투영할 방향이 없으니 영벡터 반환.- 스칼라 계수
(a·b)/(b·b)를 구한다 — 이것이 "b를 몇 배 해야 하는가". - 그 계수를
b에 곱해 b 방향의 벡터로 되돌린다.
// 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는
항상 양수). 즉 a가 b와 같은 쪽을 보면 투영이 b와 같은 방향,
반대쪽을 보면 b와 반대 방향으로 나옵니다. 크기는 a가 b 방향으로 얼마나
"멀리 가는가"에 비례합니다.
perp = a - projectOnto(a, b).
이 perp는 b에 정확히 수직인 성분입니다. 경사면에서 중력을 "면을 따라 미끄러지는
힘(수직 성분)"과 "면을 누르는 힘(투영 성분)"으로 나눌 때 이 한 쌍을 그대로 씁니다.
손계산 예제
a = (3, 4, 0)를 b = (1, 0, 0)(x축)에 투영해 봅시다.
a·b = 3*1 + 4*0 + 0*0 = 3b·b = 1*1 + 0 + 0 = 1- 계수
= 3 / 1 = 3 proj = 3 * (1,0,0) = (3, 0, 0)
(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 * scalar | XMVectorScale(b, k) | 벡터에 스칼라 곱 |
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), 반사 계산 — 표면이 향하는 방향이 필요한 모든 곳의 출발점입니다.
- 한 정점
p0를 기준으로 두 변 벡터e1 = p1 − p0,e2 = p2 − p0를 만든다. - 두 변의 외적
e1 × e2를 구한다 → 삼각형 평면에 수직인 벡터. - 정규화해서 길이를 1로 맞춘다(법선은 방향만 중요, 크기는 1로 통일).
// 삼각형 법선: 두 모서리의 외적을 정규화. 정점 순서가 법선 방향을 결정한다.
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가:
- 반시계(CCW)로 감기면 → 법선이 당신(화면 밖)을 향합니다.
- 시계(CW)로 감기면 → 법선이 화면 안쪽을 향합니다.
이건 왼손 법칙을 정점 순서에 적용한 결과입니다. 어느 쪽이 "앞면"인지는 API 규약이 정합니다.
cross(e2, e1)) 결과에 -1을 곱하면 됩니다.
래스터라이저 상태(D3D11_RASTERIZER_DESC::FrontCounterClockwise)로 규약 자체를 바꿀 수도 있습니다.
왜 normalize가 필요한가
외적의 길이는 |e1||e2|sinθ = 삼각형 평행사변형의 넓이라, 삼각형이 클수록 법선도
길어집니다. 하지만 법선은 방향만 의미 있고 크기는 필요 없습니다. 조명 공식(N·L)이
올바로 작동하려면 법선의 길이가 정확히 1이어야 하므로 normalize로 통일합니다.
e1과 e2가 평행이 되어 외적이 영벡터가 됩니다. 그러면 normalize가
0으로 나눠 NaN을 냅니다. 신뢰할 수 없는 메시 데이터를 다룬다면
cross 결과의 길이가 임계값보다 큰지 먼저 확인하세요.
손계산 예제
p0=(0,0,0), p1=(1,0,0), p2=(0,1,0) (xy 평면 위 삼각형).
e1 = p1-p0 = (1,0,0),e2 = p2-p0 = (0,1,0)cross(e1,e2)성분별:- x = e1.y*e2.z − e1.z*e2.y = 0*0 − 0*1 = 0
- y = e1.z*e2.x − e1.x*e2.z = 0*0 − 1*0 = 0
- z = e1.x*e2.y − e1.y*e2.x = 1*1 − 0*0 = 1
- 외적 =
(0, 0, 1), 이미 길이 1이라 정규화 후에도(0,0,1)
xy 평면 삼각형의 법선이 +z(평면에 수직) — 정확합니다. 왼손 좌표계에서 +z는
화면 안쪽을 향하므로, 이 감김 순서는 D3D 기준으로 뒷면을 향하는 셈입니다. 앞면 법선을 원하면
p1과 p2를 맞바꾸면 (0,0,-1)이 나옵니다.
DirectXMath 대응
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 - p0 | XMVectorSubtract | 변 벡터 만들기(레인별 뺄셈) |
cross(e1,e2) | XMVector3Cross | 외적(왼손, 순서 동일) |
normalize(...) | XMVector3Normalize | 길이 1로 |
4) 2D 외적 부호 — cross2D
무엇을 만드나: 2D에서 두 벡터의 "외적"에 해당하는 스칼라 하나를 냅니다. 왜 필요한가: 이 값의 부호가 "점이 선의 어느 쪽에 있나", "삼각형이 앞/뒤로 감겼나(winding)", "세 점이 좌회전/우회전하나"를 즉시 알려줍니다. 2D 피킹·볼록 껍질·삼각형 채우기의 기본 원자 연산입니다.
// 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 > 0 —
b가a의 왼쪽에 있음(반시계, 좌회전 / CCW). - cross2D = 0 —
a와b가 평행(같은 직선 위, 일직선). - cross2D < 0 —
b가a의 오른쪽에 있음(시계, 우회전 / CW).
크기 |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(1, 0, 0, 1) = 1*1 − 0*0 = 1 > 0→ 반시계(좌회전). x축에서 y축으로는 왼쪽으로 도는 게 맞습니다.- 순서를 바꿔
cross2D(0, 1, 1, 0) = 0*0 − 1*1 = −1 < 0→ 시계. 순서를 뒤집으니 부호가 뒤집힙니다(외적의 반대칭성).
cross2D는 곱셈 2번, 뺄셈 1번뿐 — sqrt·acos 없이 부호만으로 방향을
판정하므로 대량 반복(래스터라이저 픽셀 루프, 충돌 넓은 페이즈)에 이상적입니다. "각도가 몇 도인가"를
묻지 말고 "어느 쪽인가"만 물으면 이렇게 싸게 끝납니다.
정리 — 언제 무엇을 쓰나
| 질문 | 도구 | 핵심 |
|---|---|---|
| 두 방향이 같은 쪽? / 얼마나? | 내적 부호 / angleBetween | 부호만 필요하면 acos 생략 |
| 이 방향 성분만 뽑아내기 | projectOnto | sqrt 없이 b·b로 |
| 면이 향하는 방향 | triangleNormal | 정점 순서가 앞뒤 결정, 정규화 필수 |
| 2D 좌/우, 감김, 점 안팎 | cross2D | 부호 = 방향, 크기 = 넓이×2 |
네 함수 모두 재료는 dot과 cross 둘뿐입니다.
내적은 "방향 일치도(스칼라)", 외적은 "수직 방향과 넓이(벡터/부호)" — 이 두 직관만 확실히 잡으면
조명·컬링·충돌·피킹의 코드가 전부 이 페이지의 조합으로 읽힙니다.