CPP로 RayTracing 구현하기 - 5. 빛 반사 (Matte무광 재질)

그래픽스꿀잼·2026년 5월 1일

그래픽스

목록 보기
13/20

Diffuse Material

matte의 뜻을 알고있음?

matte 는 무광택이라는 뜻임

그니까 예를들어
의자의 메쉬, 천옷같은것들이 matte재질임

이러한 matte한 재질을 빛을 diffuse시킨다고 함

유광 재질은 빛의 입사각과 반사각이 일정함
하지만 무광 재질은 빛이 이상하게 흡수, 다른 방향, 엉뚱한 방향, 정방향등 랜덤하게 방향이 바뀜

주로 이렇게 빛이 랜덤하게 반사가 됨

물체가 어두울수록 빛이 더 흡수되어 잘 안보이게 되는거임

그리고 빛을 랜덤하게 반사하게만 하면, diffuse material처럼 보임ㅇㅇ

그러니 일단 무작위 Vector를 만드는 코드를 추가하자

//Vector3.h ...
//랜덤 벡터
static Vector3 random()
{
    return Vector3(random_double(), random_double(), random_double());
}

static Vector3 random(double min, double max)
{
    return Vector3(random_double(min, max), random_double(min, max), random_double(min, max));
}
//...

하지만 이 방법으로는 완전한 구 내부의 벡터를 찾을 수 없음
어떤 벡터는 구 외부로 나갈것이고, 어떤 벡터는 바라보는 구 방향의 반대방향을 가리키고 있을것이고,, 어떤 벡터는 구 내부에서만 있을것임.

rejection 샘플링

이때 필요한게 rejection method기반의 샘플링임

Rejection Method

랜덤에 의존적인 샘플링 기법임

특정 랜덤값을 생성하고, 해당 값이 특정 조건이 부합하는지를 판단하여 사용하는 것을 Rejection Method라고 부름

따라서 우리가 할 건, 아래의 요구사항을 구현하는 거임

  1. 반지름이 정규화된 벡터인 1의 크기를 가지는 구가 있다고 가정
  2. 그 구를 둘러싼 큐브가 있고, 구의 지름의 변의 크기를 가짐
  3. 구의 중심에서부터 랜덤으로 모든 요소의 값이 -1~1사이의 값을 가지는 점을 하나 찍음
  4. 해당 점이 정규화된 반지름의 크기를 가지는 구 내부에 위치(크기가 특정 값 이하)한다면 해당 점을 이용해 구의 중심에서부터 표면까지 정규화된 벡터를 구함
  5. 이 벡터가 원하는 반구의 방향이 아닌, 다른 방향에 위치한다면 -를 곱해 원하는 방향을 향하도록 만들어준다
    1. 2.

따라서 아래처럼 랜덤한 점을 찍고, 해당 점이 구 내부에 위치한다면
단위벡터크기를 가지는 벡터를 만드는 함수를 만들어줌

inline Vector3 random_unit_vector()
{
    while (true)
    {
        Vector3 point = Vector3::random(-1, 1);
        double lenSq = point.length_squared();

        //벡터 길이가 1보다 작으면 무조건 구 안에 존재, 이를 단위벡터로 정규화
        if (lenSq <= 1) return point / sqrt(lenSq);
    }
}

근데 문제가 있음

일단 length_square 메서드를 살펴보자

double length_squared() const
{
    return e[0] * e[0] + e[1] * e[1] + e[2] * e[2];
}

Vector3의 각 요소를 제곱하여 더한 값이 벡터크기이니까
저런 코드가 만들어짐

만약 Vector3의 각 요소가 구의 중심과 매우매우 가까워서 거의 0의 값을 가진다고 가정해보자

double은 소수점 아래 최대 15자리?정도까지 소수점 표현이 가능함.
따라서 특정 좌표가 0.00000000001, 0.00000000001, 0.00000000001라고 할때, 길이를 계산하면 0이라는 값이 나옴.

그리고 이 값을 이용해 정규화를 하면, 벡터/벡터크기라는 식인데, 이는 division by zero식이 되어버림

그래서 이를 방지해줘야함


정밀도 vs 표현범위

10e-160의 이유

double형의 소수점 아래는 15~18자리임
이는 정밀도라고 부름

정밀도는 소수점 아래 몇자리까지 정확하게 표현 가능한가를 의미함

하지만 표현범위는 조금 다름

표현범위는 표현할 수 있는 숫자의 범위를 의미함

double형의 정밀도는 15~18자리인데 반해
double형의 표현범위는 ±2.2310308\pm 2.23 * 10^{-308} ~ ±1.810308\pm1.8 * 10^{308}

따라서 표현범위를 커버하기위해 -308~308이라는 범위를 커버하기위해 제곱시 320이라는 지수를 가지는 160이 채택된거임

지수 154의 제곱이 308이라 딱 떨어지는거 아님??

맞음
Quora > In Python, why is 1e-323 = 0 false, but 1e-324 = 0 is true? How/why was -324 defined/chosen?

c++ fundamental - range of values

를 살펴보면
이렇게 min sub normal이 1032410^{-324}라고 되어있는것을 볼 수 있음

이 값까지 표현하기위해 제곱시 324이하가 되고, 그냥 계산하기 편해서 그럼ㅇㅇ

1. rejection샘플링을 통해 랜덤 점 -> 정규화된 벡터 구하기

그래서 코드는 다음과 같아짐

inline Vector3 random_unit_vector()
{
    while (true)
    {
        Vector3 point = Vector3::random(-1, 1);
        double lenSq = point.length_squared();

        //벡터 길이가 1보다 작으면 무조건 구 안에 존재, 이를 단위벡터로 정규화
        if (1e-160 < lenSq && lenSq <= 1) return point / sqrt(lenSq);
    }
}

2. 구한 정규화된 벡터의 방향이 원하는 반구의 방향이 아닐때

벡터의 방향이 원하는 반구의 방향이 아닐때, -를 곱해주면 됨.

이때 사용하는건 역시나 내적으로,

두 벡터 랜덤 점에서부터 정규화된 벡터
Ray를 쏴서 구와의 충돌지점 좌표를 구한 후, 구의 중심에서부터 충돌좌표까지 방향이 구 표면에서의 Normal이기 때문에 이렇게 구한 정규화된 Normal벡터

를 내적을 통해 값이 0 이하면 랜덤 점에서부터 정규화된 벡터는 원하는 반구 방향이 아닌, 반대방향을 가리키고 있다는 뜻으로 -를 곱해 방향을 뒤집어준다!

가 됨

이 각도는 정규화 normal벡터 기준
00^\circ ~ 9090^\circ사이에 위치하거나
270270^\circ ~ 360360^\circ사이에 위치할때, 반구 방향임

색상을 결정짓는 코드를 아래처럼 손봐주면 됨!

//Camera.h...
Color ray_color(const Ray& r, const Hittable& world) const
{
    HitRecord rec;
    if (world.hit(r, MinMaxInterval(0, infinity), rec))
    {
        //기존의 hit된 지점에서 normal방향으로 1번 반사에서
        //hit된 지점에서 랜덤한 바라보는 반구방향으로 재귀적으로 반사로 바꿈
        Vector3 dir = random_on_hemisphere(rec.normal);
        return 0.5 * ray_color(Ray(rec.p, dir), world);
    }
        
    Vector3 unit_direction = unit_vector(r.direction());
        
    double t = 0.5 * (unit_direction.y() + 1.0);
    
    return (1.0 - t) * Color(1.0, 1.0, 1.0) + t * Color(0.5, 0.7, 1.0);
}

재귀 문제

하지만 이 코드는 심각한 결점이 있음

일단 stack overflow라고 들어봤지?

재귀가 너무 많이 일어나면 메모리 스택이 터져서 시스템이 뻗어버리는거임ㅇㅇ

지금 코드는 계속해서 재귀를 호출할때 랜덤한 벡터를 만들고, 그 벡터가 구 안에 존재할때 ~~~hemisphere메서드가 호출이 되는거지

근데 만약

여기서 재귀호출로 hit이 계속 되면 어떻게 되지?

ㅇㅇ그냥 잠재적 시스템 폭파범임

그래서 이걸 해결하기위해 재귀의 최대 깊이를 설정해줘야함

//Camera.h...

public:
	int    max_depth    = 10; //재귀 호출 최대 반복 수
    
    //...
    
    void render(const Hittable& world) 
    {
        //...
        for (int sample = 0; sample < samples_per_pixel; sample++)
        {
        	Ray r = get_ray(i,j);
        	pixel_color += ray_color(r, max_depth, world); //max_depth추가
        }
		//...
    }
    
    //...
private:
	Color ray_color(const Ray& r, int depth, const Hittable& world) const
    {
        if (depth <= 0) return Color(0,0,0);
        
        HitRecord rec;
        if (world.hit(r, MinMaxInterval(0, infinity), rec))
        {
            //기존의 hit된 지점에서 normal방향으로 1번 반사에서
            //hit된 지점에서 랜덤한 바라보는 반구방향으로 재귀적으로 반사로 바꿈
            Vector3 dir = random_on_hemisphere(rec.normal);
            return 0.5 * ray_color(Ray(rec.p, dir), depth - 1, world);
        }
        
        Vector3 unit_direction = unit_vector(r.direction());
        
        double t = 0.5 * (unit_direction.y() + 1.0);
        
        return (1.0 - t) * Color(1.0, 1.0, 1.0) + t * Color(0.5, 0.7, 1.0);
    }

이렇게 depth를 이용하도록 해주면 됨

그리고 main에서 max_depth를 적절한 값으로 초기화 시키셈

그럼 결과가 아래처럼 나옴

기가막힌다 이거!!

Lambertian Reflection

람베르트 반사라고 불리는 빛 반사 알고리즘임

유니티 쉐이더 기본 - 빛, 램버트 조명 모델
위 링크를 참고해서 약간의 지식을 얻고 가보자

위의 빛 반사는 임시적인 빛 반사로직으로, 실제의 빛 반사와는 거리가 좀 멀음

단순히 Matte재질은 빛을 난반사 한다는 특성을 이용해 구현한거임

핵심 아이디어는 다음과 같음

  1. Ray를 쏴서 구의 표면에 점하는 접점을 PP라고 부름
  2. 점 P에서 표면은 2개임
    • 하나는 Normal이 구 바깥으로 뻗는 면
    • 하나는 Normal이 구 내부로 뻗는 면
  3. 따라서 이를 이용해 반지름이 단위벡터인 구를 그린다고 가정
  4. 구 바깥으로 Normal이 뻗을때 : 구의중심=P+N구의 중심 = P+N
    • 구 내부로 Normal이 뻗을때 : 구의중심=PN구의 중심 = P-N
  5. P+NP+N일때, 구의 단위벡터 반경에서 특정 점을 SS라고 부름

Ray와 구의 접점 : PP
구의 접점에서 구의 바깥방향의 Normal : NN

단위벡터를 반지름으로 가지는 바깥방향 Normal의 구의 중심 : CC
C=P+NC = P + N

구의 단위벡터에 있는 랜덤한 점 : SS,
구의 중심에서 랜덤 단위크기 벡터 : RR
S=C+R=(P+N)+RS = C + R = (P+N)+R

구의 단위벡터에 있는 랜덤한 점에서 Ray와 구의 접점 방향
SP=(C+R)P=(P+N)+RP=N+RS-P = (C+R) - P = (P+N)+R - P = N + R

따라서 접점PP에서 Normal과 Random단위크기 벡터를 더하면
접점PP에서 단위벡터 반지름크기 구의 랜덤 접점SS까지의 크기가 나옴
이걸 이용해서 빛을 계산!

//Camera.h...
Color ray_color(const Ray& r, int depth, const Hittable& world) const
{
    if (depth <= 0) return Color(0,0,0);
        
    HitRecord rec;
    if (world.hit(r, MinMaxInterval(0, infinity), rec))
    {
        //기존의 hit된 지점에서 normal방향으로 1번 반사에서
        //hit된 지점에서 랜덤한 바라보는 반구방향으로 재귀적으로 반사로 바꿈
        Vector3 dir = rec.normal + random_unit_vector(); //N + R
        return 0.5 * ray_color(Ray(rec.p, dir), depth - 1, world);
    }
        
    Vector3 unit_direction = unit_vector(r.direction());
        
    double t = 0.5 * (unit_direction.y() + 1.0);
        
    return (1.0 - t) * Color(1.0, 1.0, 1.0) + t * Color(0.5, 0.7, 1.0);
}

자 여기서 다시한번 rec.normal이 어떻게 계산되는지 살펴보자

rec.normal 다시보기

//Camera.h : ray_color메서드
if (world.hit(r, MinMaxInterval(0, infinity), rec))

world는 hittableList객체의 변수명임

hittableList.h의 hit를 살펴보자

//HittableList.h

bool hit(const Ray& r, MinMaxInterval ray_t, HitRecord& hitRec) const override
{
    HitRecord tempRec;
    bool doesHitAnything = false;
    double closestSoFar = ray_t.max;

    for (const shared_ptr<Hittable>& obj : objs)
    {
        if (obj -> hit(r, MinMaxInterval(ray_t.min, closestSoFar), tempRec))
        {
            doesHitAnything = true;
            closestSoFar = tempRec.t;
            hitRec = tempRec;
        }
    }

    return doesHitAnything;
}

반복문을 보면 objs라는 배열내의 객체들에 대해 반복문을 돌리고 잇음

그리고 각 객체에 대해 hit메서드를 execute중임

그럼 obj는 Hittable객체이고, Hittable객체는 현재 sphere이니,
Sphere.h의 hit를 살펴보자

//Sphere.h

bool hit(const Ray& r, MinMaxInterval ray_t, HitRecord& hitRec) const override
{
    Vector3 oc = center - r.origin();
    double a = r.direction().length_squared();
    double half_b = dot(r.direction(), oc);
    double c = oc.length_squared() - radius * radius;
    double discriminant = half_b * half_b - a * c;

    if (discriminant < 0)
    {
        return false;
    }
    
    //sqrt연산은 무겁기때문에, 캐싱해둠
    double sqrtDis = sqrt(discriminant);

    //이동거리 스칼라 t에 대해 tmin ~ tmax사이를 이동한 거리 t에 대해서만 구와 ray의 교차범위를 찾음
    double root = (half_b - sqrtDis) / a;

    //tmax와 tmin사이에 없는 경우 false리턴
    if (!ray_t.surrounds(root))
    {
        root = (half_b + sqrtDis) / a;
        if (!ray_t.surrounds(root))
        {
            return false;
        }
    }

    hitRec.t = root;
    hitRec.p = r.at(hitRec.t);
    Vector3 outward_normal = (hitRec.p - center) / radius;
    hitRec.set_face_normal(r, outward_normal);

    return true;
}

구체 그리기 포스트에서 살펴본 근의 공식을 이용해 Ray와 구의 접점을 찾는 공식이 들어가 있음

먼저 a, b, c를 계산하고, 조건에 따라 근을 찾아냄

근이 모든 조건에 부합한다면

  1. hitRect(이동거리 스칼라값)을 근으로 초기화함
    tRay에서부터 구의 접점까지의 이동거리가 됨
  2. t를 이용해서 Ray의 원점에서부터 t만큼 이동한 거리에 있는 좌표를 hitRecp를 초기화함
  3. hitRec.p - 구의 중심을 통해 구의 구의 중심에서부터 p까지 이어지는 Normal을 구하고, 구의 반지름으로 나누어 단위벡터로 만듬
    • 구의 중심 ~ p까지의 벡터는 접점p에서 구 바깥으로 향하는 Normal임
  4. hitRec의 Normal을 Ray의 방향과 구한 단위벡터 Normal을 내적하여 Normal이 앞면인지, 뒷면인지 판단 후에 값을 그대로 사용하거나 반전시켜 hitRec.normal에 3번에서 구한 Normal을 초기화

감마 보정

Color ray_color(const Ray& r, int depth, const Hittable& world) const
{
    if (depth <= 0) return Color(0,0,0);
    
    HitRecord rec;
    if (world.hit(r, MinMaxInterval(0.001, infinity), rec))
    {
        //기존의 hit된 지점에서 normal방향으로 1번 반사에서
        //hit된 지점에서 랜덤한 바라보는 반구방향으로 재귀적으로 반사로 바꿈
        Vector3 dir = rec.normal + random_unit_vector();

        //0.9값을 0.1~0.9까지 0.2씩 증가하며 계산
        return 0.9 * ray_color(Ray(rec.p, dir), depth - 1, world);
    }
    
    Vector3 unit_direction = unit_vector(r.direction());

    double t = 0.5 * (unit_direction.y() + 1.0);

    return (1.0 - t) * Color(1.0, 1.0, 1.0) + t * Color(0.5, 0.7, 1.0);
}

이렇게 구체와 hit가 되었을때 색을 0.5가 아닌, 원하는 값으로 설정해보자
.
난 교재에서 말하는대로 0.1~0.9까지 0.2단위로 렌더링해봄

그럼 이렇게 왼쪽 0.1~오른쪽 0.9까지 색상이 렌더링됨

이때 0.5의 값을 가지는 구간인 중간 이미지를 살펴보자

저 색상이 과연 흰색인 (1,1,1)과 검은색인 (0,0,0)사이인 (0.5, 0.5, 0.5)라고 할 수 있을까?
한번 color picker로 찍어보자

자, 약간의 하늘색이 섞여있다면 실제 회색인 (0.5, 0.5, 0.5)보다 살짝 더 높은 값이거나, 비슷하거나, 약간 낮은 값이 나오겠지?

이 색은 회색보다 예상밖의 어두운 색이라는 거임

이 이유는 감마 보정때문임

감마 보정 (Gamma Correction)

먼저 감마를 알아야함

감마란 입력값과 출력 휘도 사이의 거듭제곱 관계의 지수(exponent)임.
수식으로 표현하면 출력=γ출력 = 입력^γ이고, γγ(감마) 값이 이 곡선의 형태를 결정함.

이때 감마는 이미지의 인코딩(저장)/디코딩(출력) 곡선의 지수임

우리 눈은 위에서 설명한것처럼 빛의 강도를 비선형적으로 받아들임...
같은 밝기의 형광등이라고 해도, 낮과 밤에 대하여 우리의 눈은 다르게 빛을 받아들임

즉, 사람 눈은 어두운 영역의 변화에 민감하고, 밝은 곳의 변화에는 민감하게 받아들이지 않음.


감마 인코딩

이미지나 비디오를 저장할때 어두운 부분을 더 밝게 만들어 저장하고 이때 지수가 γ=1/2.2γ = 1/2.2

어두운 부분을 밝게 만드는 이유는 다음과 같음
먼저, 이미지는 휘도를 비트로 저장함. 비트는 0~255까지 256단계임.

감마 인코딩을 하지 않고 그대로 이미지를 저장하면,
이미지의 모든 부분에 선형적으로 비트를 분배하기때문에 밝은부분에 비트가 더 몰리게 됨.

반면 어두운 부분은 비트가 적게 분배되어 색의 계조표현이 부족하게 됨.

계조 : 명암을 디지털로 표현할 때 밝은 부분(명부)과 어두운 부분(암부)으로 이어지는 단계의 차이

따라서 이를 해결하기 위해
어두운 비트를 더 밝게 만들어 많은 비트를 부여함으로써 계조를 늘려 더 세밀한 이미지 색상을 표현할 수 있도록 저장할 수 잇는 거임

감마 인코딩 공식 = XγX^\gamma,
XX : 입력 이미지 픽셀당 밝기 비트,
γ=1/2.2^\gamma = 1/2.2

감마 디코딩

저장된 이미지를 디스플레이에 출력할때 어둡게 만들음

이미 하드웨어적으로 이미지는 인코딩되었다고 가정하여,
출력을 할때 밝아졌던 부분을 어둡게 만들어버림

그것을 γ=2.2γ = 2.2 혹은 감마2.2 라고 부름

감마 디코딩 공식 = XγX^\gamma,
XX : 입력 이미지 픽셀당 밝기 비트,
γ=2.2^\gamma = 2.2

오른쪽 사진을 보면
선형의 이미지를 출력하면 원본보다 어두운 감마를 가진 이미지가 출력됨
감마 인코딩된 이미지를 출력하면 원본과 같은 감마를 가진 이미지가 출력됨


따라서 우리는 출력할 이미지를 인코딩된 감마 이미지로 만들고, 이것을 자동으로 디코딩되록 하여 원본의 이미지 색을 구할거임

근데 2.2라는 값은 좀 계산하기 복잡함...

그러니 2.2의 근삿값인 2를 이용할거임

지금 우리에게 필요한건 이 이미지를 직접 손수 감마 인코딩을 해줘야함.

즉, 감마 인코딩 공식인 γ=1/2^\gamma = 1/2를 이용할거임

따라서 인코딩 공식에 따라 픽셀당 밝기를 XX이라고 한다면, X12X^\frac{1}{2} 을 해주면 됨.

그리고 특정 수 XX12\frac{1}{2} 제곱은 XX의 제곱근을 구하는 것과 같음

중요한건 색상이 0보다 작거나 같은 값인 경우, 유효한 제곱근을 구할 수 없으므로 예외처리 해줘야함

//Color.h

//...

inline double incode_gamma(double linear)
{
    if (linear > 0)
    {
        return sqrt(linear);
    }

    return 0;
}

void write_color(std::ostream& out, const Color& pixel_color) {
    double r = pixel_color.x();
    double g = pixel_color.y();
    double b = pixel_color.z();

    //감마 인코딩
    r = incode_gamma(r);
    g = incode_gamma(g);
    b = incode_gamma(b);

    //...
}

#endif

이렇게 코드를 수정해주면 됨

그럼 아래와 같이 원본 이미지의 색상을 볼 수 있음

반사율감마 인코딩 전감마 인코딩 후
0.1
0.3
0.5
0.7
0.9
전체
profile
그래픽스 공부중

0개의 댓글