TL;DR — 레이트레이서는 모든 픽셀에 광선을 쏘고, 물체와 만나는 거리 를 구하는 프로그램입니다. 구의 방정식에 광선 식을 대입하면 이차방정식이 나오고, 판별식으로 충돌 여부를 판정합니다. 충돌점에 Phong 조명(ambient + diffuse + specular)을 적용하면 입체감이 생깁니다.
Table of contents
Open Table of contents
들어가며
Emberlit 프로젝트의 단계 2에서 CPU 레이트레이서를 구현했습니다. 이 글에서는 광선을 코드로 표현하는 것부터 구와의 충돌 판정, 그리고 Phong 조명까지의 과정을 정리합니다.
이 글에서 다루는 내용:
- 매개변수 직선으로 광선 표현하기
- 광선-구 충돌: 이차방정식과 판별식
- Phong 조명 모델: ambient, diffuse, specular
사전 지식: 벡터의 내적(dot product)에 대한 기본 이해를 전제로 합니다.
1. 화면의 모든 픽셀에 광선을 쏜다
게임 화면에 3D 물체가 보이려면, “이 픽셀에 무슨 색을 칠할지”를 결정해야 합니다. 레이트레이서는 가장 직관적인 방법을 씁니다 — 모든 픽셀에서 광선을 하나씩 쏘고, 뭔가에 맞으면 그 색을 칠합니다.
그러면 첫 번째 문제는 “광선”을 코드로 어떻게 표현하느냐입니다.
1.1 왜 1차 함수로는 안 되는가
직선을 표현하는 가장 익숙한 식은 1차 함수 입니다. (가로 위치)를 넣으면 (세로 위치)가 나오고, 는 기울기(직선이 얼마나 기울어졌는지), 는 y절편(직선이 y축과 만나는 점)입니다. 2D 평면에서 직선을 그릴 때는 충분합니다.
하지만 레이트레이서에서는 쓸 수 없습니다:
- 수직선을 표현할 수 없습니다 — 같은 수직선은 x가 변하지 않고 y만 변합니다. 기울기 가 무한대가 되어 식에 넣을 수 없습니다.
- 3D로 확장이 안 됩니다 — 는 를 넣으면 가 나오는 2D 전용 식입니다. z축이 추가되는 3D 공간을 표현할 방법이 없습니다.
- 시작점과 방향이 없습니다 — 레이트레이서에서는 “카메라에서 출발해서, 이 방향으로 쏴서, 얼마나 가서 맞았는가”를 알아야 합니다. 는 직선 전체를 표현할 뿐, 어디서 출발하는지와 어느 방향인지를 담을 수 없습니다.
1.2 매개변수 직선:
결국 레이트레이서에서는 시작점, 방향, 그리고 얼마나 갔는지 세 가지가 필요합니다. 이를 그대로 수식으로 쓰면:
- : 광선의 출발점 (카메라 위치)
- : 방향 (단위 벡터)
- : 출발점에서 얼마나 갔는지 (스칼라)
에 숫자를 넣으면 직선 위의 점이 하나 나옵니다. 이면 출발점, 이면 출발점에서 방향으로 5만큼 간 지점입니다. 가 음수면 카메라 뒤쪽이라 무시합니다.
여기서 (카메라 위치)와 (광선 방향)는 이미 알고 있는 값입니다. 모르는 건 — 광선이 물체에 닿기까지 얼마나 갔는지입니다. 만 구하면:
- 충돌 지점: 로 바로 계산
- 충돌 거리: 자체가 카메라로부터의 거리 (가 단위벡터일 때)
- 앞뒤 판별: 여러 물체에 맞았을 때, 가 가장 작은 게 카메라에 가장 가까운 물체
레이트레이서의 핵심 작업은 결국 하나입니다 — 물체와 만나는 를 구하는 것. 만 알면 나머지는 전부 따라옵니다.
struct Ray {
glm::vec3 origin;
glm::vec3 direction; // 항상 normalized
};
2. 광선이 구에 맞았는지 어떻게 아는가
를 구하면 충돌점을 알 수 있다고 했습니다. 그러면 가장 단순한 물체인 구부터 시도해봅니다. 구는 방정식 하나로 정의되고, 광선 식을 대입하면 이차방정식이 바로 나와서 를 구하는 과정을 익히기에 가장 좋습니다. (삼각형은 2단계로 나눠야 해서 더 복잡합니다 — 다음 편에서 다룹니다.)
2.1 구의 방정식
구의 표면은 중심 에서 반지름 만큼 떨어진 점들의 집합입니다:
“점 에서 중심 까지의 거리가 반지름 과 같다.” 양변을 제곱하면 sqrt를 없앨 수 있습니다:
2.2 왜 광선 식을 여기에 대입하는가
이 식에서 는 “구 표면 위의 아무 점”입니다. 그리고 광선 는 “직선 위의 아무 점”을 만듭니다. 두 조건을 동시에 만족하는 점 — 광선 위에도 있고, 구 표면 위에도 있는 점 — 이 바로 충돌점입니다.
그래서 자리에 를 넣습니다:
(카메라 위치), (광선 방향), (구 중심), (반지름)은 전부 알고 있는 값입니다. 모르는 건 뿐. 이 식을 에 대해 풀면 됩니다.
를 로 놓고 내적을 전개하면, 에 대한 이차방정식이 나옵니다:
| 계수 | 수식 | 코드 |
|---|---|---|
glm::dot(ray.direction, ray.direction) | ||
2.0f * glm::dot(oc, ray.direction) | ||
glm::dot(oc, oc) - radius * radius |
2.3 판별식 — 맞았는지 빗나갔는지
이차방정식 의 해가 존재하는지를 판별식 로 판단할 수 있습니다. 판별식은 근의 공식에서 제곱근 안에 들어가는 값인데, 이 값의 부호로 해의 개수가 결정됩니다:
- : 제곱근 안이 음수 → 실수 해 없음 → 광선이 구를 빗나감
- : 해가 정확히 1개 → 접선 (스치고 지나감)
- : 해가 2개 → 관통 (들어가는 점 + 나가는 점)
기하학적으로 보면, 직선이 구와 만나는 교점은 0, 1, 2개만 가능합니다 — 이차방정식이니까요.
2.4 근의 공식 — 구하기
판별식이 0 이상이면 근의 공식으로 를 구합니다:
에서 를 쓰면 (가까운 충돌점), 를 쓰면 (먼 충돌점)이 나옵니다. 는 항상 양수이므로 빼는 쪽이 더 작은 값 → 카메라에 가까운 점입니다.
보통은 더 작은 해 이 카메라에서 먼저 만나는 앞면이고, 는 구를 뚫고 나가는 뒷면입니다. 하지만 항상 만 쓰면 안 됩니다. 광선 시작점이 구 바깥에 있으면 이 정답이지만, 구 안에서 시작하면 , 이 됩니다. 이때는 가 실제로 화면에 보이는 출구 쪽 충돌점입니다.
결론은 단순합니다 — 두 해 중에서 가장 작은 양수 해를 고릅니다. 두 해가 모두 음수면 구가 카메라 뒤에 있는 것이므로 무시합니다.
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. 구는 보이는데, 왜 납작해 보이는가
충돌 판정만으로는 구가 단색 원으로 보입니다. 입체감이 없습니다. 현실에서 공이 둥글게 보이는 이유는 빛 때문입니다 — 빛을 정면으로 받는 면은 밝고, 비스듬히 받는 면은 어둡습니다.
그러면 “표면의 각 지점이 빛을 얼마나 받는가”를 계산해야 합니다. 여기서 필요한 벡터 두 개:
- : 충돌점의 법선 (표면이 어디를 향하는지)
- : 충돌점에서 광원 방향 (
normalize(lightPos - hitPoint))
빛이 표면에 정면으로 비추면 (과 이 같은 방향) 밝고, 비스듬하면 어둡습니다. 이 “각도에 따른 밝기”를 내적 하나로 구할 수 있습니다:
과 이 단위벡터일 때, 내적 결과는 — 각도가 0°면 1.0(최대), 90°면 0.0(빛 없음)입니다. 음수가 나오면 빛이 뒤에서 오는 거라 0으로 자릅니다.
Tip: 가 성립하려면 둘 다 단위벡터여야 합니다. 조명 계산에 사용하는 방향 벡터(, , , )는 반드시
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는 “빛이 거울처럼 특정 방향으로 반사된다”입니다. 그래서 이번에는 카메라 위치가 계산에 들어갑니다 — 반사된 빛이 카메라를 향할 때만 반짝입니다.
필요한 벡터:
- : 빛의 반사 방향
- : 충돌점에서 카메라 방향 (
normalize(-ray.direction))
반사 벡터 은 입사 벡터를 법선 기준으로 뒤집은 것입니다:
과 가 가까울수록 (내적이 클수록) 카메라가 반사광을 정면으로 받아서 밝게 보입니다:
여기서 pow가 핵심입니다. 는 0.0~1.0 사이 값인데, 거듭제곱하면 1.0에 가까운 영역만 살아남고 나머지는 빠르게 0에 수렴합니다:
| 결과 | |
|---|---|
| 0.8 (넓은 하이라이트) | |
| 0.107 | |
| 0.00002 (거의 점) |
가 클수록 하이라이트가 좁고 날카롭고, 작을수록 넓고 부드럽습니다. 즉 광택의 퍼짐 정도를 조절하는 값입니다. 이 는 물리에서 유도한 값이 아니라 “이 정도면 그럴듯하다”고 정하는 경험적 값입니다. Phong 모델이 경험적 모델이라 불리는 이유입니다.
최종 색은 세 요소를 합산합니다:
- ambient: 빛이 안 닿는 면이 완전히 검정이 되지 않게 하는 기본 밝기
- diffuse: 표면과 빛의 각도에 따른 밝기 (카메라 무관)
- 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이면 색이 깨지기 때문입니다.
정리하며
핵심 요약:
- 광선은 로 표현하고, 핵심은 를 구하는 것
- 구의 방정식에 광선을 대입하면 이차방정식 → 판별식으로 충돌 여부 판정
- Lambert diffuse는 , 카메라 위치 무관
- Phong specular는 , 카메라 위치 관련
- 최종 색 = ambient + diffuse + specular
참고 자료
- Bui Tuong Phong, “Illumination for Computer Generated Pictures”, Communications of the ACM, 1975 — Phong 모델 원논문. 물리적 유도 없이 경험적 모델로 제시.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.