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

[Emberlit] CPU 레이트레이서 (1): 광선이 구를 만나는 순간

TL;DR — 레이트레이서는 모든 픽셀에 광선을 쏘고, 물체와 만나는 거리 tt를 구하는 프로그램입니다. 구의 방정식에 광선 식을 대입하면 이차방정식이 나오고, 판별식으로 충돌 여부를 판정합니다. 충돌점에 Phong 조명(ambient + diffuse + specular)을 적용하면 입체감이 생깁니다.

Table of contents

Open Table of contents

들어가며

Emberlit 프로젝트의 단계 2에서 CPU 레이트레이서를 구현했습니다. 이 글에서는 광선을 코드로 표현하는 것부터 구와의 충돌 판정, 그리고 Phong 조명까지의 과정을 정리합니다.

이 글에서 다루는 내용:

사전 지식: 벡터의 내적(dot product)에 대한 기본 이해를 전제로 합니다.


1. 화면의 모든 픽셀에 광선을 쏜다

게임 화면에 3D 물체가 보이려면, “이 픽셀에 무슨 색을 칠할지”를 결정해야 합니다. 레이트레이서는 가장 직관적인 방법을 씁니다 — 모든 픽셀에서 광선을 하나씩 쏘고, 뭔가에 맞으면 그 색을 칠합니다.

그러면 첫 번째 문제는 “광선”을 코드로 어떻게 표현하느냐입니다.

1.1 왜 1차 함수로는 안 되는가

직선을 표현하는 가장 익숙한 식은 1차 함수 y=ax+by = ax + b입니다. xx(가로 위치)를 넣으면 yy(세로 위치)가 나오고, aa는 기울기(직선이 얼마나 기울어졌는지), bb는 y절편(직선이 y축과 만나는 점)입니다. 2D 평면에서 직선을 그릴 때는 충분합니다.

하지만 레이트레이서에서는 쓸 수 없습니다:

1.2 매개변수 직선: P(t)=O+tDP(t) = O + tD

결국 레이트레이서에서는 시작점, 방향, 그리고 얼마나 갔는지 세 가지가 필요합니다. 이를 그대로 수식으로 쓰면:

P(t)=O+tDP(t) = O + tD

tt에 숫자를 넣으면 직선 위의 점이 하나 나옵니다. t=0t = 0이면 출발점, t=5t = 5이면 출발점에서 DD 방향으로 5만큼 간 지점입니다. tt가 음수면 카메라 뒤쪽이라 무시합니다.

여기서 OO(카메라 위치)와 DD(광선 방향)는 이미 알고 있는 값입니다. 모르는 건 tt — 광선이 물체에 닿기까지 얼마나 갔는지입니다. tt만 구하면:

레이트레이서의 핵심 작업은 결국 하나입니다 — 물체와 만나는 tt를 구하는 것. tt만 알면 나머지는 전부 따라옵니다.

struct Ray {
    glm::vec3 origin;
    glm::vec3 direction;  // 항상 normalized
};

2. 광선이 구에 맞았는지 어떻게 아는가

tt를 구하면 충돌점을 알 수 있다고 했습니다. 그러면 가장 단순한 물체인 부터 시도해봅니다. 구는 방정식 하나로 정의되고, 광선 식을 대입하면 이차방정식이 바로 나와서 tt를 구하는 과정을 익히기에 가장 좋습니다. (삼각형은 2단계로 나눠야 해서 더 복잡합니다 — 다음 편에서 다룹니다.)

2.1 구의 방정식

구의 표면은 중심 CC에서 반지름 rr만큼 떨어진 점들의 집합입니다:

XC=r|X - C| = r

“점 XX에서 중심 CC까지의 거리가 반지름 rr과 같다.” 양변을 제곱하면 sqrt를 없앨 수 있습니다:

(XC)(XC)=r2(X - C) \cdot (X - C) = r^2

2.2 왜 광선 식을 여기에 대입하는가

이 식에서 XX는 “구 표면 위의 아무 점”입니다. 그리고 광선 P(t)=O+tDP(t) = O + tD는 “직선 위의 아무 점”을 만듭니다. 두 조건을 동시에 만족하는 점 — 광선 위에도 있고, 구 표면 위에도 있는 점 — 이 바로 충돌점입니다.

그래서 XX 자리에 O+tDO + tD를 넣습니다:

(O+tDC)(O+tDC)=r2(O + tD - C) \cdot (O + tD - C) = r^2

OO(카메라 위치), DD(광선 방향), CC(구 중심), rr(반지름)은 전부 알고 있는 값입니다. 모르는 건 tt뿐. 이 식을 tt에 대해 풀면 됩니다.

OCO - Cococ로 놓고 내적을 전개하면, tt에 대한 이차방정식이 나옵니다:

t2(DD)+2t(ocD)+(ococr2)=0t^2(D \cdot D) + 2t(oc \cdot D) + (oc \cdot oc - r^2) = 0
계수수식코드
aaDDD \cdot Dglm::dot(ray.direction, ray.direction)
bb2(ocD)2(oc \cdot D)2.0f * glm::dot(oc, ray.direction)
ccococr2oc \cdot oc - r^2glm::dot(oc, oc) - radius * radius

2.3 판별식 — 맞았는지 빗나갔는지

이차방정식 at2+bt+c=0at^2 + bt + c = 0의 해가 존재하는지를 판별식 Δ=b24ac\Delta = b^2 - 4ac로 판단할 수 있습니다. 판별식은 근의 공식에서 제곱근 안에 들어가는 값인데, 이 값의 부호로 해의 개수가 결정됩니다:

기하학적으로 보면, 직선이 구와 만나는 교점은 0, 1, 2개만 가능합니다 — 이차방정식이니까요.

2.4 근의 공식 — tt 구하기

판별식이 0 이상이면 근의 공식으로 tt를 구합니다:

t=b±b24ac2at = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}

±\pm에서 -를 쓰면 t1t_1(가까운 충돌점), ++를 쓰면 t2t_2(먼 충돌점)이 나옵니다. \sqrt{}는 항상 양수이므로 빼는 쪽이 더 작은 값 → 카메라에 가까운 점입니다.

t1=bb24ac2at_1 = \frac{-b - \sqrt{b^2 - 4ac}}{2a}

보통은 더 작은 해 t1t_1이 카메라에서 먼저 만나는 앞면이고, t2t_2는 구를 뚫고 나가는 뒷면입니다. 하지만 항상 t1t_1만 쓰면 안 됩니다. 광선 시작점이 구 바깥에 있으면 t1t_1이 정답이지만, 구 안에서 시작하면 t1<0t_1 < 0, t2>0t_2 > 0이 됩니다. 이때는 t2t_2가 실제로 화면에 보이는 출구 쪽 충돌점입니다.

결론은 단순합니다 — 두 해 중에서 가장 작은 양수 해를 고릅니다. 두 해가 모두 음수면 구가 카메라 뒤에 있는 것이므로 무시합니다.

glm::vec3 oc = ray.origin - center;
float a = glm::dot(ray.direction, ray.direction);
float b = 2.0f * glm::dot(oc, ray.direction);
float c = glm::dot(oc, oc) - radius * radius;
float discriminant = b * b - 4 * a * c;

if (discriminant < 0) return false;

float sqrtD = glm::sqrt(discriminant);
float t1 = (-b - sqrtD) / (2.0f * a);
float t2 = (-b + sqrtD) / (2.0f * a);

float t = t1;
if (t < 0.0f) t = t2;
if (t < 0.0f) return false;  // 두 해 모두 카메라 뒤쪽

hit.t = t;
hit.point = ray.origin + t * ray.direction;
hit.normal = glm::normalize(hit.point - center);

충돌점을 구했으면 법선도 바로 나옵니다 — 구 중심에서 충돌점 방향이 표면의 바깥 방향이니까 normalize(P - C)입니다.


3. 구는 보이는데, 왜 납작해 보이는가

충돌 판정만으로는 구가 단색 원으로 보입니다. 입체감이 없습니다. 현실에서 공이 둥글게 보이는 이유는 빛 때문입니다 — 빛을 정면으로 받는 면은 밝고, 비스듬히 받는 면은 어둡습니다.

그러면 “표면의 각 지점이 빛을 얼마나 받는가”를 계산해야 합니다. 여기서 필요한 벡터 두 개:

빛이 표면에 정면으로 비추면 (NNLL이 같은 방향) 밝고, 비스듬하면 어둡습니다. 이 “각도에 따른 밝기”를 내적 하나로 구할 수 있습니다:

brightness=max(NL,  0)\text{brightness} = \max(N \cdot L, \; 0)

NNLL이 단위벡터일 때, 내적 결과는 cosθ\cos\theta — 각도가 0°면 1.0(최대), 90°면 0.0(빛 없음)입니다. 음수가 나오면 빛이 뒤에서 오는 거라 0으로 자릅니다.

Tip: NL=cosθN \cdot L = \cos\theta가 성립하려면 둘 다 단위벡터여야 합니다. 조명 계산에 사용하는 방향 벡터(LL, NN, EE, RR)는 반드시 normalize해야 합니다. 빠뜨리면 내적 결과가 cosine이 아니라 엉뚱한 값이 되어 조명이 전부 틀어집니다.

이것이 **Lambert 확산 조명(diffuse)**입니다. 물리적으로는 빛이 비스듬히 오면 같은 에너지가 더 넓은 면적에 퍼져서 단위 면적당 밝기가 줄어드는 것입니다.

이 공식에 카메라 위치가 없다는 점이 중요합니다. 어느 방향에서 보든 같은 밝기 — 이것이 “빛이 모든 방향으로 균일하게 퍼진다(확산)“는 의미입니다.

glm::vec3 L = glm::normalize(scene.lightPos - hit.point);
glm::vec3 diffuse = hit.color * glm::max(glm::dot(hit.normal, L), 0.0f);

4. 금속 광택은 어떻게 만드는가

Diffuse만으로는 종이나 나무 같은 매트한 느낌만 납니다. 금속이나 플라스틱의 반짝이는 하이라이트를 표현하려면 Specular가 필요합니다.

Diffuse는 “빛이 모든 방향으로 퍼진다”였다면, Specular는 “빛이 거울처럼 특정 방향으로 반사된다”입니다. 그래서 이번에는 카메라 위치가 계산에 들어갑니다 — 반사된 빛이 카메라를 향할 때만 반짝입니다.

필요한 벡터:

반사 벡터 RR은 입사 벡터를 법선 기준으로 뒤집은 것입니다:

R=2(NL)NLR = 2(N \cdot L)N - L

RREE가 가까울수록 (내적이 클수록) 카메라가 반사광을 정면으로 받아서 밝게 보입니다:

specular=pow(max(RE,  0),  α)\text{specular} = \text{pow}(\max(R \cdot E, \; 0), \; \alpha)

여기서 pow가 핵심입니다. RER \cdot E는 0.0~1.0 사이 값인데, 거듭제곱하면 1.0에 가까운 영역만 살아남고 나머지는 빠르게 0에 수렴합니다:

RE=0.8R \cdot E = 0.8결과
α=1\alpha = 10.8 (넓은 하이라이트)
α=10\alpha = 100.107
α=100\alpha = 1000.00002 (거의 점)

α\alpha가 클수록 하이라이트가 좁고 날카롭고, 작을수록 넓고 부드럽습니다. 즉 광택의 퍼짐 정도를 조절하는 값입니다. 이 α\alpha는 물리에서 유도한 값이 아니라 “이 정도면 그럴듯하다”고 정하는 경험적 값입니다. Phong 모델이 경험적 모델이라 불리는 이유입니다.

최종 색은 세 요소를 합산합니다:

color=ambient+diffuse+specular\text{color} = \text{ambient} + \text{diffuse} + \text{specular}
glm::vec3 L = glm::normalize(scene.lightPos - hit.point);
glm::vec3 E = glm::normalize(-ray.direction);
float NdotL = glm::max(glm::dot(hit.normal, L), 0.0f);

glm::vec3 ambient  = 0.1f * hit.color;
glm::vec3 diffuse  = hit.color * NdotL;
glm::vec3 specular = glm::vec3(0.0f);

if (NdotL > 0.0f) {
    glm::vec3 R = 2.0f * NdotL * hit.normal - L;
    specular = glm::vec3(1.0f) * glm::pow(glm::max(glm::dot(R, E), 0.0f), hit.specularAlpha);
}

return glm::clamp(ambient + diffuse + specular, glm::vec3(0.0f), glm::vec3(1.0f));

여기서 NdotL을 먼저 max(..., 0.0)로 자른 이유가 중요합니다. 빛이 표면 뒤에 있으면 diffuse는 0이어야 하고, specular도 같이 꺼져야 합니다. clamp는 세 요소를 더했을 때 1.0을 넘을 수 있어서 — 예를 들어 diffuse 0.8 + specular 0.5 = 1.3이면 색이 깨지기 때문입니다.


정리하며

핵심 요약:

참고 자료


다음 편: CPU 레이트레이서 (2): 삼각형 충돌과 그림자


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


공유하기:

이전 글
[Emberlit] CPU 레이트레이서 (2): 삼각형 충돌과 그림자
다음 글
[Vulkan] renderFinished 세마포어는 왜 프레임이 아니라 이미지에 묶어야 하는가