TL;DR — 삼각형 충돌은 무한 평면과의 교점을 먼저 구한 뒤, 소삼각형 외적으로 내부를 판별하는 2단계입니다. Shadow ray는 충돌점에서 광원으로 2차 광선을 쏴서 그림자를 판정합니다. 백페이스 컬링, winding order, epsilon offset 등 수학적으로 맞는 식과 렌더링에서 안전한 구현은 다를 수 있습니다.
Table of contents
Open Table of contents
들어가며
이전 편에서 광선-구 충돌과 Phong 조명까지 구현했습니다. 이번 편에서는 실제 렌더링의 기본 단위인 삼각형과의 충돌, shadow ray를 이용한 그림자, 그리고 구현 중 만난 함정들을 정리합니다.
이 글에서 다루는 내용:
- 광선-삼각형 충돌: 평면 교점 + 내부 판별
- 백페이스 컬링과 winding order
- Shadow ray와 epsilon offset
- 직교/원근 투영과 삼각형이 안 보이는 경우
사전 지식: 이전 편(CPU 레이트레이서 (1))의 광선 표현과 내적/외적에 대한 이해를 전제로 합니다.
1. 구만으로는 세상을 만들 수 없다
게임에서 보이는 모든 3D 물체 — 캐릭터, 건물, 지형 — 은 삼각형으로 만들어집니다. 여기서 용어를 정리하고 갑니다.
- 폴리곤(polygon): 다각형. 삼각형, 사각형, 오각형 등을 모두 포함하는 넓은 의미
- 메쉬(mesh): 폴리곤 여러 개를 그물망처럼 이어붙여 하나의 3D 형태를 만든 것
- 트라이앵글 메쉬(triangle mesh): 삼각형만으로 구성된 메쉬
GPU가 3D 물체를 화면 픽셀로 변환하는 과정(래스터라이저)의 기본 처리 단위가 삼각형입니다. 사각형 이상의 폴리곤은 꼭지점들이 같은 평면에 있지 않을 수 있어서 처리가 복잡하지만, 삼각형은 점 3개가 항상 하나의 평면을 만들기 때문에 안전합니다. 모델링 툴에서 사각형으로 만들어도, 엔진에 넘어갈 때 삼각형으로 분할(triangulation)됩니다.
광선-삼각형 충돌은 구보다 복잡합니다. 구는 이차방정식 하나로 끝나지만, 삼각형은 유한한 영역이라 추가 판정이 필요합니다.
2. 두 단계로 나눠서 풀기
삼각형과 직접 충돌을 구하는 것은 복잡합니다. 대신 문제를 두 개로 쪼갭니다:
- 삼각형 세 점이 만드는 무한 평면과 광선의 교점 를 구한다
- 그 가 삼각형 내부에 있는지 확인한다
2.1 평면과 만나는 점 구하기
평면을 정의하려면 두 가지면 됩니다 — 평면 위의 점 하나()와 법선 .
법선은 삼각형의 두 변을 외적하면 나옵니다:
평면 위에 있는 점 는 에서 까지의 벡터가 법선과 수직이어야 합니다:
1편에서 구의 방정식에 를 대입한 것과 같은 원리입니다. 는 “평면 위의 아무 점”이고, 광선 는 “직선 위의 아무 점”을 만듭니다. 두 조건을 동시에 만족하는 점 — 광선 위에도 있고 평면 위에도 있는 점 — 이 교점입니다. , , , 은 전부 아는 값이니까 모르는 만 풀면 됩니다:
정리하면:
구처럼 이차방정식을 풀 필요도 없습니다.
2.2 분모 이 말해주는 것
를 구하는 식의 분모는 — 광선 방향 와 평면 법선 의 내적입니다. 이 값은 를 계산하기 전에 반드시 먼저 확인해야 합니다. 분모가 0에 가까우면 나누기가 터지고, 값의 부호로 광선이 앞면을 보는지 뒷면을 보는지도 판단할 수 있습니다.
삼각형에는 앞면과 뒷면이 있습니다. 법선 이 가리키는 쪽이 앞면이고, 반대가 뒷면입니다. 카메라가 뒷면을 보고 있으면 물체 내부를 보는 것이라 그릴 필요가 없습니다 — 이것이 **백페이스 컬링(backface culling)**입니다.
앞뒤를 판정하려면 “표면에서 카메라를 향하는 방향”과 법선을 비교하면 됩니다. 표면에서 카메라 방향은 광선의 반대 방향, 즉 입니다:
- 와 이 같은 쪽 (내적 > 0) → 카메라가 앞면을 봄
- 와 이 반대 쪽 (내적 < 0) → 카메라가 뒷면을 봄 → 컬링
의 부호를 직접 확인해도 되지만, 내적의 성질()을 이용하면 이미 구해둔 으로 바로 판정할 수 있습니다:
| 값 | 의미 | 해석 |
|---|---|---|
| — | 광선이 평면과 평행 → 만나지 않음 | |
| 뒷면 → 백페이스 컬링 | ||
| 앞면 → 유효한 충돌 |
평행()과 백페이스()를 합치면, 이 0 이상인 경우를 전부 걸러내면 됩니다. 코드 한 줄로 처리 가능합니다:
float dDotN = glm::dot(ray.direction, normal);
if (dDotN > -1e-6f) return false; // 평행 + 백페이스 한 번에 처리
이 체크를 계산 전에 해야 0으로 나누기를 방지할 수 있습니다.
2.3 Winding order
삼각형의 세 꼭지점 , , 의 나열 순서를 winding order라고 합니다. 앞면에서 봤을 때:
- CCW (반시계 방향): 가 반시계로 돌아감
- CW (시계 방향): 시계로 돌아감
그래픽스에서는 CCW를 앞면으로 두는 관례가 매우 흔합니다. OpenGL은 기본 front face가 CCW이고, Vulkan도 파이프라인에서 front face를 명시할 때 보통 CCW를 많이 씁니다. 법선을 구할 때 의 방향은 winding order에 의해 결정되므로, CCW로 정의된 삼각형은 법선이 앞면을 향하게 됩니다. winding order가 뒤집히면 법선도 뒤집혀서 백페이스 컬링에 걸립니다.
2.4 소삼각형 외적으로 내부 판별
로 원래 삼각형을 소삼각형 3개로 쪼개고, 각 소삼각형의 외적 방향이 전부 법선 과 같은 방향이면 내부입니다:
각 외적의 순서가 변 방향 → P 방향으로 통일되어 있다는 점이 중요합니다. 이 순서가 삼각형의 winding order(CCW)를 따르고 있어서, 가 내부에 있으면 외적 결과가 전부 법선 과 같은 방향으로 나옵니다.
직관: 삼각형 변을 따라 순서(CCW)로 걸어갈 때, 가 내부에 있으면 각 변에서 봤을 때 P가 항상 왼쪽에 있습니다. 하나라도 오른쪽으로 넘어가면 그 외적이 뒤집혀서 내적이 음수가 됩니다.
실제 코드에서는 > 0보다 작은 epsilon을 둔 >= -eps 판정이 더 안전합니다. 부동소수점 오차 때문에 변 위에 있어야 할 점이 아주 작은 음수로 튈 수 있기 때문입니다.
constexpr float kEdgeEpsilon = 1e-6f;
hit.point = ray.origin + t * ray.direction;
glm::vec3 N0 = glm::cross(v1 - v0, hit.point - v0);
glm::vec3 N1 = glm::cross(v2 - v1, hit.point - v1);
glm::vec3 N2 = glm::cross(v0 - v2, hit.point - v2);
if (glm::dot(normal, N0) >= -kEdgeEpsilon &&
glm::dot(normal, N1) >= -kEdgeEpsilon &&
glm::dot(normal, N2) >= -kEdgeEpsilon) {
// 내부 — 충돌 확정
}
3. 빛이 닿지 않는 곳: Shadow Ray
조명까지 구현하면 구에 음영이 생깁니다. 하지만 바닥에 그림자가 없습니다. 현실에서는 구 아래 바닥에 그림자가 져야 합니다.
아이디어는 단순합니다 — 충돌점 에서 광원을 향해 **두 번째 광선(shadow ray)**을 쏩니다. 이 광선이 다른 물체에 막히면 그림자, 아무것도 안 맞으면 빛을 받습니다.
그림자 안이면 diffuse와 specular를 빼고 ambient만 반환합니다:
bool Raytracer::isInShadow(...) {
glm::vec3 shadowOrigin = point + normal * 1e-4f;
Ray shadowRay{shadowOrigin, lightDir};
Hit h = findClosestHit(shadowRay, scene, ViewMode::Shadow);
return h.hit() && h.t < lightDist; // 광원보다 가까운 물체가 가림
}
// shade() 내부
if (isInShadow(hit.point, hit.normal, toLight, lightDist, scene)) {
return ambient;
}
Tip: shadow ray의 origin을 충돌점 그대로 쓰면, 부동소수점 오차로 가 표면 살짝 안쪽에 있을 수 있습니다. 그러면 자기 자신과 충돌해서 모든 곳이 그림자가 됩니다. origin을 법선 방향으로 아주 조금() 띄워서 방지합니다.
4. 삼각형이 안 보일 때 확인할 것
삼각형 충돌을 구현했는데 바닥이 안 보인다면, 다음 순서로 확인합니다.
Tip 1: 직교 투영이라면 원근 투영으로 바꿔보기
직교 투영은 모든 픽셀에서 방향으로 광선을 쏩니다. 수평 바닥()의 법선은 이라 광선과 수직 → → 평행 판정으로 충돌이 아예 발생하지 않습니다. 원근 투영으로 바꾸면 광선이 부채꼴로 퍼지면서 아래쪽 광선이 바닥에 닿습니다.
// 원근: 눈에서 각 픽셀 방향으로 ray.origin = scene.eyePos; ray.direction = glm::normalize(pixelPos - scene.eyePos);
Tip 2: 그래도 안 보이면 winding order 확인하기
법선이 카메라 반대 방향을 향하고 있으면 백페이스 컬링에 걸립니다. 의 방향은 정점 순서로 결정되므로, 앞면에서 봤을 때 가 **CCW(반시계)**인지 확인합니다. CW로 되어 있으면 법선이 뒤집혀서 안 보입니다.
Tip 3: 외적 방향을 직관으로 확인할 때는 카메라와 축 방향을 함께 보기
외적 공식 자체는 바뀌지 않습니다.
cross(a, b)는 언제나 같은 식으로 계산합니다. 다만 화면에서 보이는 앞/뒤 감각은 “카메라가 어느 축을 보고 있는지”, “를 앞이라고 둘지 뒤라고 둘지” 같은 좌표계 약속 때문에 헷갈릴 수 있습니다. 손가락 법칙을 좌표계마다 바꿔 외우기보다, winding order와cross(V1 - V0, V2 - V0)결과를 숫자로 직접 확인하는 편이 안전합니다.
5. 최종 결과

구 3개(빨강, 초록, 파랑)에 Phong 조명이 적용되고, 바닥에 그림자가 드리워진 모습입니다. 모든 픽셀을 CPU에서 하나씩 계산한 결과입니다.
정리하며
이번 시리즈에서 중요했던 건 레이트레이싱이 결국 거리와 방향을 일관되게 해석하는 문제라는 점이었습니다. 광선 식 에서 시작한 는 단순히 “맞았는가”를 넘어서, 가장 가까운 물체를 고르고 shadow ray가 광원까지 가기 전에 다른 물체에 막히는지 판정하는 기준이 됩니다.
조명에서도 같은 감각이 이어졌습니다. 은 diffuse 밝기만 계산하는 값이 아니라, 이 빛이 표면에 직접 기여할 수 있는지를 먼저 거르는 기준입니다. 그래서 이면 diffuse뿐 아니라 specular도 함께 꺼져야 합니다.
삼각형 충돌은 수식 하나로 끝나지 않았습니다. 평면과의 교점을 구한 뒤, 그 점이 삼각형 내부인지 따로 판정해야 했고, winding order, 백페이스 컬링, shadow acne를 막기 위한 epsilon offset까지 함께 다뤄야 했습니다. 수학적으로 맞는 식과 렌더링에서 안전한 구현은 다를 수 있다는 점이 이번 시리즈의 가장 큰 정리였습니다.
핵심 요약:
- 광선-삼각형 충돌은 2단계: 무한 평면 교점 → 소삼각형 내부 판별
- 백페이스 컬링과 평행 체크를
dDotN > -1e-6f한 줄로 처리 - Shadow ray는 충돌점에서 광원으로 2차 광선, epsilon offset 필수
- Winding order(CCW)가 법선 방향을 결정하고, 뒤집히면 안 보임
참고 자료
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.