본문으로 건너뛰기
뒤로가기

[Emberlit] CPU 레이트레이서 (2): 삼각형 충돌과 그림자

TL;DR — 삼각형 충돌은 무한 평면과의 교점을 먼저 구한 뒤, 소삼각형 외적으로 내부를 판별하는 2단계입니다. Shadow ray는 충돌점에서 광원으로 2차 광선을 쏴서 그림자를 판정합니다. 백페이스 컬링, winding order, epsilon offset 등 수학적으로 맞는 식과 렌더링에서 안전한 구현은 다를 수 있습니다.

Table of contents

Open Table of contents

들어가며

이전 편에서 광선-구 충돌과 Phong 조명까지 구현했습니다. 이번 편에서는 실제 렌더링의 기본 단위인 삼각형과의 충돌, shadow ray를 이용한 그림자, 그리고 구현 중 만난 함정들을 정리합니다.

이 글에서 다루는 내용:

사전 지식: 이전 편(CPU 레이트레이서 (1))의 광선 표현과 내적/외적에 대한 이해를 전제로 합니다.


1. 구만으로는 세상을 만들 수 없다

게임에서 보이는 모든 3D 물체 — 캐릭터, 건물, 지형 — 은 삼각형으로 만들어집니다. 여기서 용어를 정리하고 갑니다.

GPU가 3D 물체를 화면 픽셀로 변환하는 과정(래스터라이저)의 기본 처리 단위가 삼각형입니다. 사각형 이상의 폴리곤은 꼭지점들이 같은 평면에 있지 않을 수 있어서 처리가 복잡하지만, 삼각형은 점 3개가 항상 하나의 평면을 만들기 때문에 안전합니다. 모델링 툴에서 사각형으로 만들어도, 엔진에 넘어갈 때 삼각형으로 분할(triangulation)됩니다.

광선-삼각형 충돌은 구보다 복잡합니다. 구는 이차방정식 하나로 끝나지만, 삼각형은 유한한 영역이라 추가 판정이 필요합니다.


2. 두 단계로 나눠서 풀기

삼각형과 직접 충돌을 구하는 것은 복잡합니다. 대신 문제를 두 개로 쪼갭니다:

  1. 삼각형 세 점이 만드는 무한 평면과 광선의 교점 PP를 구한다
  2. PP삼각형 내부에 있는지 확인한다

2.1 평면과 만나는 점 구하기

평면을 정의하려면 두 가지면 됩니다 — 평면 위의 점 하나(V0V_0)와 법선 NN.

법선은 삼각형의 두 변을 외적하면 나옵니다:

N=normalize(cross(V1V0,  V2V0))N = \text{normalize}(\text{cross}(V_1 - V_0, \; V_2 - V_0))

평면 위에 있는 점 PPV0V_0에서 PP까지의 벡터가 법선과 수직이어야 합니다:

(PV0)N=0(P - V_0) \cdot N = 0

1편에서 구의 방정식에 O+tDO + tD를 대입한 것과 같은 원리입니다. PP는 “평면 위의 아무 점”이고, 광선 O+tDO + tD는 “직선 위의 아무 점”을 만듭니다. 두 조건을 동시에 만족하는 점 — 광선 위에도 있고 평면 위에도 있는 점 — 이 교점입니다. OO, DD, V0V_0, NN은 전부 아는 값이니까 모르는 tt만 풀면 됩니다:

(O+tDV0)N=0(O + tD - V_0) \cdot N = 0

정리하면:

t=(V0O)NDNt = \frac{(V_0 - O) \cdot N}{D \cdot N}

구처럼 이차방정식을 풀 필요도 없습니다.

2.2 분모 DND \cdot N이 말해주는 것

tt를 구하는 식의 분모는 DND \cdot N — 광선 방향 DD와 평면 법선 NN의 내적입니다. 이 값은 tt를 계산하기 전에 반드시 먼저 확인해야 합니다. 분모가 0에 가까우면 나누기가 터지고, 값의 부호로 광선이 앞면을 보는지 뒷면을 보는지도 판단할 수 있습니다.

삼각형에는 앞면과 뒷면이 있습니다. 법선 NN이 가리키는 쪽이 앞면이고, 반대가 뒷면입니다. 카메라가 뒷면을 보고 있으면 물체 내부를 보는 것이라 그릴 필요가 없습니다 — 이것이 **백페이스 컬링(backface culling)**입니다.

앞뒤를 판정하려면 “표면에서 카메라를 향하는 방향”과 법선을 비교하면 됩니다. 표면에서 카메라 방향은 광선의 반대 방향, 즉 D-D입니다:

dot(D,N)\text{dot}(-D, N)의 부호를 직접 확인해도 되지만, 내적의 성질(dot(D,N)=dot(D,N)\text{dot}(-D, N) = -\text{dot}(D, N))을 이용하면 이미 구해둔 DND \cdot N으로 바로 판정할 수 있습니다:

DND \cdot NDN-D \cdot N 의미해석
0\approx 0광선이 평면과 평행 → 만나지 않음
>0> 0dot(D,N)<0\text{dot}(-D, N) < 0뒷면 → 백페이스 컬링
<0< 0dot(D,N)>0\text{dot}(-D, N) > 0앞면 → 유효한 충돌

평행(0\approx 0)과 백페이스(>0> 0)를 합치면, DND \cdot N이 0 이상인 경우를 전부 걸러내면 됩니다. 코드 한 줄로 처리 가능합니다:

float dDotN = glm::dot(ray.direction, normal);
if (dDotN > -1e-6f) return false;  // 평행 + 백페이스 한 번에 처리

이 체크를 tt 계산 전에 해야 0으로 나누기를 방지할 수 있습니다.

2.3 Winding order

삼각형의 세 꼭지점 V0V_0, V1V_1, V2V_2나열 순서를 winding order라고 합니다. 앞면에서 봤을 때:

그래픽스에서는 CCW를 앞면으로 두는 관례가 매우 흔합니다. OpenGL은 기본 front face가 CCW이고, Vulkan도 파이프라인에서 front face를 명시할 때 보통 CCW를 많이 씁니다. 법선을 구할 때 cross(V1V0,V2V0)\text{cross}(V_1 - V_0, V_2 - V_0)의 방향은 winding order에 의해 결정되므로, CCW로 정의된 삼각형은 법선이 앞면을 향하게 됩니다. winding order가 뒤집히면 법선도 뒤집혀서 백페이스 컬링에 걸립니다.

2.4 소삼각형 외적으로 내부 판별

PP로 원래 삼각형을 소삼각형 3개로 쪼개고, 각 소삼각형의 외적 방향이 전부 법선 NN과 같은 방향이면 내부입니다:

N0=cross(V1V0,  PV0)N_0 = \text{cross}(V_1 - V_0, \; P - V_0) N1=cross(V2V1,  PV1)N_1 = \text{cross}(V_2 - V_1, \; P - V_1) N2=cross(V0V2,  PV2)N_2 = \text{cross}(V_0 - V_2, \; P - V_2) N0Nε    N1Nε    N2Nε    내부 또는 변 위N_0 \cdot N \ge -\varepsilon \;\wedge\; N_1 \cdot N \ge -\varepsilon \;\wedge\; N_2 \cdot N \ge -\varepsilon \;\Rightarrow\; \text{내부 또는 변 위}

각 외적의 순서가 변 방향 → P 방향으로 통일되어 있다는 점이 중요합니다. 이 순서가 삼각형의 winding order(CCW)를 따르고 있어서, PP가 내부에 있으면 외적 결과가 전부 법선 NN과 같은 방향으로 나옵니다.

직관: 삼각형 변을 따라 V0V1V2V_0 \to V_1 \to V_2 순서(CCW)로 걸어갈 때, PP가 내부에 있으면 각 변에서 봤을 때 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

조명까지 구현하면 구에 음영이 생깁니다. 하지만 바닥에 그림자가 없습니다. 현실에서는 구 아래 바닥에 그림자가 져야 합니다.

아이디어는 단순합니다 — 충돌점 PP에서 광원을 향해 **두 번째 광선(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을 충돌점 PP 그대로 쓰면, 부동소수점 오차로 PP가 표면 살짝 안쪽에 있을 수 있습니다. 그러면 자기 자신과 충돌해서 모든 곳이 그림자가 됩니다. origin을 법선 방향으로 아주 조금(10410^{-4}) 띄워서 방지합니다.


4. 삼각형이 안 보일 때 확인할 것

삼각형 충돌을 구현했는데 바닥이 안 보인다면, 다음 순서로 확인합니다.

Tip 1: 직교 투영이라면 원근 투영으로 바꿔보기

직교 투영은 모든 픽셀에서 (0,0,1)(0, 0, 1) 방향으로 광선을 쏩니다. 수평 바닥(y=1y = -1)의 법선은 (0,1,0)(0, 1, 0)이라 광선과 수직 → DN=0D \cdot N = 0 → 평행 판정으로 충돌이 아예 발생하지 않습니다. 원근 투영으로 바꾸면 광선이 부채꼴로 퍼지면서 아래쪽 광선이 바닥에 닿습니다.

// 원근: 눈에서 각 픽셀 방향으로
ray.origin = scene.eyePos;
ray.direction = glm::normalize(pixelPos - scene.eyePos);

Tip 2: 그래도 안 보이면 winding order 확인하기

법선이 카메라 반대 방향을 향하고 있으면 백페이스 컬링에 걸립니다. cross(V1V0,V2V0)\text{cross}(V_1 - V_0, V_2 - V_0)의 방향은 정점 순서로 결정되므로, 앞면에서 봤을 때 V0V1V2V_0 \to V_1 \to V_2가 **CCW(반시계)**인지 확인합니다. CW로 되어 있으면 법선이 뒤집혀서 안 보입니다.

Tip 3: 외적 방향을 직관으로 확인할 때는 카메라와 축 방향을 함께 보기

외적 공식 자체는 바뀌지 않습니다. cross(a, b)는 언제나 같은 식으로 계산합니다. 다만 화면에서 보이는 앞/뒤 감각은 “카메라가 어느 축을 보고 있는지”, “z+z+를 앞이라고 둘지 뒤라고 둘지” 같은 좌표계 약속 때문에 헷갈릴 수 있습니다. 손가락 법칙을 좌표계마다 바꿔 외우기보다, winding order와 cross(V1 - V0, V2 - V0) 결과를 숫자로 직접 확인하는 편이 안전합니다.


5. 최종 결과

최종 결과 — 원근 투영 + Phong 조명 + 바닥 삼각형 + 그림자

구 3개(빨강, 초록, 파랑)에 Phong 조명이 적용되고, 바닥에 그림자가 드리워진 모습입니다. 모든 픽셀을 CPU에서 하나씩 계산한 결과입니다.


정리하며

이번 시리즈에서 중요했던 건 레이트레이싱이 결국 거리와 방향을 일관되게 해석하는 문제라는 점이었습니다. 광선 식 P(t)=O+tDP(t) = O + tD에서 시작한 tt는 단순히 “맞았는가”를 넘어서, 가장 가까운 물체를 고르고 shadow ray가 광원까지 가기 전에 다른 물체에 막히는지 판정하는 기준이 됩니다.

조명에서도 같은 감각이 이어졌습니다. NLN \cdot L은 diffuse 밝기만 계산하는 값이 아니라, 이 빛이 표면에 직접 기여할 수 있는지를 먼저 거르는 기준입니다. 그래서 NL0N \cdot L \le 0이면 diffuse뿐 아니라 specular도 함께 꺼져야 합니다.

삼각형 충돌은 수식 하나로 끝나지 않았습니다. 평면과의 교점을 구한 뒤, 그 점이 삼각형 내부인지 따로 판정해야 했고, winding order, 백페이스 컬링, shadow acne를 막기 위한 epsilon offset까지 함께 다뤄야 했습니다. 수학적으로 맞는 식과 렌더링에서 안전한 구현은 다를 수 있다는 점이 이번 시리즈의 가장 큰 정리였습니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[Emberlit] Camera: worldUp hint로 right를 복원할 수 있는 이유
다음 글
[Emberlit] CPU 레이트레이서 (1): 광선이 구를 만나는 순간