목적
3차원상의 구를 화면에 보는 것.
Ray Tracing 역방향 광추적
화면 상 픽셀마다 Ray(광선)을 쏴서 물체에 닿으면 그 물체의 색을 픽셀에 그려주는 원리라 볼 수 있다.
렌더 부분의 변경
void Render(std::vector<glm::vec4> &pixels) { std::fill(pixels.begin(), pixels.end(), vec4{0.0f, 0.0f, 0.0f, 1.0f}); #pragma omp parallel for for (int j = 0; j < height; j++) for (int i = 0; i < width; i++) { const vec3 pixelPosWorld = TransformScreenToWorld(vec2(i, j)); // 광선의 방향 벡터 // 스크린에 수직인 z방향, 절대값 1.0인 유닉 벡터 // Orthographic projection (정투영) vs perspective projection (원근투영) const auto rayDir = vec3(0.0f, 0.0f, 1.0f); Ray pixelRay{pixelPosWorld, rayDir}; // index에는 size_t형 사용 (index가 음수일 수는 없으니까) // traceRay()의 반환형은 vec3 (RGB), A는 불필요 pixels[size_t(i + width * j)] = vec4(traceRay(pixelRay), 1.0f); } }
Ray를 쏴서 Render 하는 부분의 코드를 잠깐 설명하자면,
fill을 통해 모든 화면의 픽셀을 검은색으로 우선은 맞춰주었다.
( 지금은 모든 픽셀들이 색을 반환하는 구조라서 사실 모든 픽셀 초기화는 필요 없고
일부 픽셀들만 광선을 쏘는 경우에는 필요한데, 해당 내용에선 다루지 않는다고 한다.)
이후 omp를 통해 멀티 쓰레딩 환경으로 좀 더 빠르게 렌더 해줄 수 있도록 하였다.
이후 모든 픽셀을 돌면서 이미지 좌표계를 월드 좌표계로 변환 시켜주었다.
ray의 방향은 왜 0,0,1로 되어있는가?
모니터의 수직의 방향으로 Ray를 하나하나 쏴주고 색을 결정한다. Ray의 방향은 x , y , z 방향 중 z 방향으로 향한다. 이것을 벡터로 표현하면 (0,0,1) 3차원 벡터로 표현할 수 있다.
가장 핵심이 되는 부분은 pixelRay를 통해 Ray를 만들어주고 traceRay를 통해 해당 Ray를 쏴준다.
그 결과를 pixel의 색깔 값으로 넣어주면 끝이 난다.
Ray Class
#pragma once #include <iostream> #include <glm/glm.hpp> #include <glm/gtx/string_cast.hpp> namespace hlab { class Ray { public: glm::vec3 start; // start position of the ray glm::vec3 dir; // direction of the ray }; }
여기서 나오는 Ray는 시작 벡터와, 방향 벡터만 가지고 있다.
Hit
#pragma once #include <iostream> #include <glm/glm.hpp> #include <glm/gtx/string_cast.hpp> namespace hlab { class Hit { public: float d;// 광선의 시작부터 충돌 지점까지의 거리 glm::vec3 point;// 충돌한 위치 glm::vec3 normal;// 충돌한 위치에서 표면의 수직 벡터 //std::shared_ptr<Object> obj; // 나중에 물체의 재질 등을 가져오기 위한 포인터 }; }
d은 광선이 충돌 했을 때의 거리를 의미하고 거리는 앞선 내용에서도 배웠듯 스칼라 값을 가진다.
point는 추후 조명과 관련된 계산을 할 때 쓰이고 이번 내용에선 쓰이질 않는다.
normal은 충돌한 위치에서 바깥으로 나가는 방향을 의미한다.
TraceRay
// 광선이 물체에 닿으면 그 물체의 색 반환 vec3 traceRay(Ray &ray) { const Hit hit = sphere->IntersectRayCollision(ray); if (hit.d < 0.0f) { return vec3(0.0f); } else { return sphere->color * hit.d; // 깊이를 곱해서 입체감 만들기 } }
traceRay는 컬러 값을 반환해준다. IntersectRay에 대해선 추후 설명하겠다.
물체와 충돌 했을 때, 어디에서 어떤 물체와 충돌했고 어떤 색을 반환해야 하는지
말 그대로 광 추적을 하는 함수이다.
hit.d 를 통해 광선의 시작점으로 부터 충돌지점까지의 거리가 음수라면 충돌을 하지 않았다는
의미를 가지게 된다. 그렇기 때문에 색상을 검은색으로 반환한다.
Sphere
#pragma once #include "Hit.h" #include "Ray.h" namespace hlab { using namespace glm; class Sphere { public: glm::vec3 center; float radius; glm::vec3 color; // 뒤에서 '재질(material)'로 확장 Sphere(const glm::vec3 ¢er, const float radius, const glm::vec3 &color) : center(center), color(color), radius(radius) { } // Wikipedia Line–sphere intersection // https://en.wikipedia.org/wiki/Line-sphere_intersection Hit IntersectRayCollision(Ray &ray) { Hit hit = Hit{-1.0f, vec3(0.0f), vec3(0.0f)}; // d가 음수이면 충돌을 안한 것으로 가정 /* * hit.d = ... // 광선의 시작점으로부터 충돌지점까지의 거리 (float) * hit.point = ... // 광선과 구가 충돌한 지점의 위치 (vec3) * hit.normal = .. // 충돌 지점에서 구의 단위 법선 벡터(unit normal vector) */ // const float a = glm::dot(ray.dir, ray.dir); // dir이 unit vector라면 a는 1.0f라서 생략 가능 return hit; } }; }
실질적으로 구현해야 하는 코드는 위의 구체를 그려주는 부분이라 할 수 있다.
잠깐 2D에서 원을 그렸던 내용을 복기해보면, 원의 방정식을 통해서 vector가 원 안에 들어있는지
를 통해 true false값을 반환해주었는데. 이번에는 조금 다르다.
Line-sphere intersection을 통해 그려야 하는데 아래 위키피디아의 글을 참고하여야 한다.
https://en.wikipedia.org/wiki/Line-sphere_intersection
사실 위 내용을 이해하기 위해 학습노트를 쓰게 되었는데. 차근차근 파해쳐 이해해 보도록 하자.
스피어를 레이트레이싱 해보자
화면 상, 3차원 구가 존재 하고 Ray를 쐈을 때, 3가지 경우를 생각 해볼 수
있다.
충돌의 경우의 수
위 이미지와 같이 레이를 쐈지만 구체와 충돌하지 않는 경우.
위 이미지와 같이 아주 절묘하게 가장자리를 딱 맞추는 경우.
충돌이 딱 1번만 일어나는 경우.
위 이미지와 같이 관통하여 충돌이 2번 일어나는 경우이다.
벡터를 이용한 계산
구의 방정식
x : 구 위의 점.
c : 중심 점.
r : 구의 반경
해당 방정식은 원의 방정식과 매우 유사하다.
차이점이라고 한다면 x 와 c가 3 차원 이라는 것 밖에 없다.
직선의 방정식
x : 광선 위의 점
o : Ray를 쏠 때 시작 점 ( Ray 를 쏘는 Pixel의 위치 )
d : 광선의 시작점 O로 부터 u 방향으로 갔을 때 얼마나 가면 충돌하는 지.
선의 원점으로부터의 거리
u : 광선의 방향 (unit Vector로 사용 하면 편하다 0,0,1 의 절대 값 1 )
방정식 결합
직선(Ray)와 구의 충돌지점을 찾기 위해선 방정식 결합이 필요하다.
간단히 구 방정식의 x와 직선 방정식의 x가 같아지는 직선 방정식의 d를
찾으면 된다.
즉 풀어서 설명하자면, 직선이 d 만큼 갔더니, 구의 방정식 x와 직선의 방정식x가 같아지더라 를 찾으면 된다.
수식으로 풀어보면 아래와 같은 식이 유도 되는데.
여기서 o+du-c를 보자면 직선의 방정식 o + du를 구의 방정식에
집어 넣었다고 볼 수 있다.
어떤 벡터의 절대값의 제곱은 같은 벡터끼리 dot product를 하면 된다.
즉 ||o+du-c||의 제곱은 같은 벡터끼리 dot product가 가능하기에 아래 식과같아진다.
또 위 식을 풀어서 전개하면 아래와 같은 식이 나타난다.
해당 식을 좀더 정리하면
이제 이것을 근의 공식을 통해 d의 값을 알아 낼 수 있다.
먼저, 근의 공식은 위와 같은 식이 나와야 하는데 위 식을 근의 공식으로 맞춰줘야 한다.
저렇게 묶어서 생각하면 저 위의 정리된 수식에 적용 해보면 아래와 같이
근의 공식이 적용 될 수 있다
자 그럼 근의 공식을 구했으니 이것을 또 풀어서 수식을 나타내어 d를 구해보자면
위와 같은 식을 도출 해낼 수 있다.
자 여기서 u는 unit vector라고 하였다. 즉 방향만 나타내는 벡터이기 때문에
a 는 결론적으로 1로 나타낼 수 있다.
위키피디아에서도 그렇게 나타난다.
또 위와 같은 근의 공식은 다소 복잡하게 보일 수 있는데. u가 1즉 단위 벡터
이기 때문에 위키피디아의 아래 식만 보면 풀이 할 수 있다.
∇ 이 친구는, 아래 위키피디아에서 정의를 알 수 있다.
https://ko.wikipedia.org/wiki/나블라
여기서 u위에 hat이 붙어 있는데 u를 노멀라이즈한 unit vector(단위 벡터)를 의미한다. 즉 1이라고 보면 된다
일단 이 나블라를 구하기 위해선, 뺄 꺼 빼고 생각해보면
이라 볼 수 있겠다.
나블라를 구한 뒤에는 우리가 원하는 d(레이를 쏴서, 구와 충돌하는 거리)를
구할 수 있는데 식을 보면
해당 식을 볼 수 있는데 즉 d에는 2개의 값을 저장해야한다.
결론적으로 이 나블라가 0보다 작으면, 충돌하지 않고
0과 같으면 충돌을 한 번 하고 0보다 크면 충돌을 2번 한다라고 볼 수 있다.
결론적으로 나블라를 계산한 후, 나블라가 0 이상이라면 d1과 d2를
계산하고, 충돌 지점 d1,d2
위 사진처럼 충돌지점이 사진1이 아닌 사진2 처럼 ray가 뒤에서 발사 되는 경우가 있다. 이때는 충돌 지점이 음수로 나올 수 있기 때문에 음수면 무시해야 한다.
결론은 hit을 반환할 때 hit의 d는 d1과 d2중 작은 것을 반환해주면 된다.
구현
시행착오(실패)
Hit IntersectRayCollision(Ray &ray) { Hit hit = Hit{-1.0f, vec3(0.0f), vec3(0.0f)}; // d가 음수이면 충돌을 안한 것으로 가정 /* * hit.d = ... // 광선의 시작점으로부터 충돌지점까지의 거리 (float) * hit.point = ... // 광선과 구가 충돌한 지점의 위치 (vec3) * hit.normal = .. // 충돌 지점에서 구의 단위 법선 벡터(unit normal vector) */ // const float a = glm::dot(ray.dir, ray.dir); // dir이 unit vector라면 a는 1.0f라서 생략 가능 const auto oc = ray.start - center; const float ocsqrt = glm::dot(oc, oc); const float nabla = ocsqrt - glm::abs(ocsqrt) - (radius * radius); if (nabla >= 0.0f) { const float d1 = -glm::dot(ray.dir,oc) + sqrt( nabla); const float d2 = -glm::dot(ray.dir,oc) - sqrt( nabla); hit.d = glm::min(d1, d2); hit.point = ray.start + hit.d; hit.normal = glm::normalize(hit.point); } return hit; }
일단 틀렸다만, 이유는 아직 못찾았다. 계산식은 간단하다 먼저 우리는 공통점으로 사용되는
계산식 o-c를 구해야 한다. 이때 o는 아까 말했듯 ray의 시작점과, c는 center를 의미한다.
이후 어떤 벡터의 제곱은 닷 프로덕트와 같다고 하였으니 float값으로 변환해주기 위해 닷프로덕트를 시도 한 모습이고 그다음 nabla를 절대값 oc제곱 빼기 radius제곱을 해줬던 것이다.
하지만 틀린 모양이다.
Hit IntersectRayCollision(Ray &ray) { Hit hit = Hit{-1.0f, vec3(0.0f), vec3(0.0f)}; // d가 음수이면 충돌을 안한 것으로 가정 /* * hit.d = ... // 광선의 시작점으로부터 충돌지점까지의 거리 (float) * hit.point = ... // 광선과 구가 충돌한 지점의 위치 (vec3) * hit.normal = .. // 충돌 지점에서 구의 단위 법선 벡터(unit normal vector) */ // const float a = glm::dot(ray.dir, ray.dir); // dir이 unit vector라면 a는 1.0f라서 생략 가능 const float b = 2.0f * glm::dot(ray.dir, ray.start - center); const float c = glm::dot(ray.start - center, ray.start - center) - (radius * radius); const float nabla = b * b / 4.0f - c; // 4.0f는 2.0f를 2번 곱해주기 때문에 if (nabla >= 0.0f) { const float d1 = -b /2.0f + sqrt(nabla); const float d2 = -b /2.0f - sqrt(nabla); hit.d = glm::min(d1, d2); hit.point = ray.start + ray.dir * hit.d; hit.normal = glm::normalize(hit.point - center); } return hit; }
이해하기 까지 꽤 어려웠다. 하나하나 차근차근 풀이를 해보고자 한다.
const float b
일단 const float b 부터 시작해보자 저 b는 위키에서 나온 그대로 식을 대입해줬다.
저것을 그대로 코드로 옮긴 것이라 보면된다 여기서 u , o , c 에 대해서 설명해보자면
직선의 방정식을 다시 한번 보면된다. u는 광선의 방향, o는 광선의 시작점 c는 원의 중심 이었다.
즉 2 * ray.dir dotproduct (ray.start - center)가 된다.
const float c
이다. 이것을 코드로 옮기면 (o-c) = (ray.start - center)가 되고 이것을 dotproduct한 후 - r의 제곱을 해주면 된다.
const float nabla
b와 c를 모두 구했으면 nabla를 구할 수 있다.
나블라 공식이 무엇이었는지 복기해보자.
였다.
여기서 hat u는 그대로 1이었고, 방향을 나타냈다. 그리고 위 식은 float b에서 2를 곱해준 것과 제곱해준걸 빼면 완전히 유사하다. 이것을 활용해서 코드로 나타난게
const float nabla = b * b / 4 - c
가 된다. 여기서 c 또한, 어떤 벡터의 절대값 제곱은 같은 벡터의 닷 프로덕트와 같다라고 하였는데.
그대로 수식을 보면 변환해서 이해할 수 있다. 즉,
그러므로 b * b가 나올 수 있는데. 어차피 u는 1이니깐, 문제는 b에선 2를 곱해주고 있다. 이게 2번 곱해지니 4가 나오므로 / 4.0f를 해주는 것이다.
그럼, nabla를 구했으니 아까 충돌의 3요소를 생각해서 조건문을 달아 구해주면 된다.
- 나블라가 0보다 낮으면 충돌 안함 즉 색상 검정색 즉 그냥 리턴 해주면 됨 ( 위에서 hit을 검정색으로 초기화 했기 때문에 )
- 나블라가 0과 같거나 크면 충돌에 대한 값을 넣어주면 됨
d1 과 d2
아까 d는 2개를 저장해야 한다고 했다. 그중 제일 낮은 값이 hit.d 로 들어가게 된다.
우선 둘 다 구하기 위해선 수식을 다시 봐야 하는데.
여기서 나블라는 위에서 구했고 hat u 닷 프로덕트 (o-c) 는 어디서 많이 봤다 그렇다. b와 같다. 2를 곱해주는 것만 빼면 말이다.
즉 아래와 같은 코드가 나올 수 있다.
const d1 = -b / 2.0f + sqrt(nabla) const d2 = -b / 2.0f - sqrt(nabla)
hit.d
이제 hit.d는 구할 수 있다 d1과 d2 중 제일 작은 값을 넣어주면 된다.
hit.point
d를 구했으면 hit point를 구할 수 있다.
아까 직선의 방정식을 다시 복기해보면
였다 즉 hit point = ray.start + d * ray.dir이 된다.
hit.normalize
법선 벡터를 구하는 부분이다. ( 이해한 부분이 틀릴 수 있음 )
이걸 다시 보자. 우리는 저 바깥으로 나가는 법선벡터를 구해야 한다 어떻게 구해야 할까?
법선벡터를 구하는 식은 크로스곱(외적)을 구할 때 배웠다. 즉 a는 hit.point b는 hit.ponit에서 center의 벡터로 보면 된다.
다만 해당 normalize는 현재 사용되지 않아서 값을 넣던 빼던 예제에선 아무런 영향을 주지 않는다.
해서 내가 이해한게 틀린지 맞는지는 검증을 하기엔 아직 지식이 부족하고 좀 더 강의를 듣고 다시
이부분을 수정하던 하겠다.
결과
이렇게 약간 center에 멀어질수록 선명하고 멀어질수록 투명한 구체를 만들 수 있었다.
이제 이 구체에 빛을 반사하는 것을 만들어 본다고 하는데 위키 백과의 수식을 실제로 코드에 적용하고 이해하는 과정 까지 매우 어려웠지만 막상 이 글을 쓰기 위해, 열심히 복습하는 과정을 거치니깐
그래도 이해할 수 있었다.