CPP로 RayTracing 구현하기 - 3. 노말벡터 구현

그래픽스꿀잼·2026년 4월 29일

그래픽스

목록 보기
11/20
post-thumbnail

노말 벡터

노말벡터 = 법선벡터

법선벡터는 그림자 표현을 위해 사용됨
이는 물체의 표면에서 수직인 벡터(외적)임

bool hit_sphere(const Point3& center, double radius, const Ray& r)
{
    Vector3 oc = center - r.origin();
    double a = dot(r.direction(), r.direction());
    double b = -2.0 * dot(r.direction(), oc);
    double c = dot(oc, oc) - radius * radius;
    double discriminant = b * b - 4 * a * c;

    return (discriminant >= 0);
}

저번시간에 위와 같이 점 CC를 중심으로 가지는 구에 대해
Ray를 쏜 후 만나는 지점 점 PP가 구에 속해있는지 아닌지를 판별하는 코드를 짰음

이때 해당 식은 ax2+bx+c=0ax^2 + bx + c = 0 형태의 이차방정식이었고,
근의 공식의 판별식을 이용해
판별식이 0보다 크면 Ray는 구를 관통하여 지나가거,
0이면 Ray는 구의 표면을,
0보다 작으면 Ray는 구를 지나가지 않는다는 개념이었음

근데 이건 판별식임

예를들어 햇빛이 뷰포트 기준 y축 높은 곳에 위치했을때,
구의 하단과 상단은 빛의 반사가 다를거임

판별식은 단순히 Ray를 쏜 후 만나는 접점 PP가 구를 지나는지 아닌지만 판별을 하는 용도임

따라서 구의 어느 부분에 Ray가 만나 접점이 생기고, 해당 접점의 거리가 어느정도인지를 알기 위해선 판별식 말고 다른 개념이 필요함

근의공식

근의공식이 필요한거임

double hit_sphere(const Point3& center, double radius, const Ray& r)
{
    Vector3 oc = center - r.origin();
    double a = dot(r.direction(), r.direction());
    double b = -2.0 * dot(r.direction(), oc);
    double c = dot(oc, oc) - radius * radius;
    double discriminant = b * b - 4 * a * c;

    if (discriminant < 0)
    {
        return -1.0;
    }
    
    return (-b - sqrt(discriminant)) / (2.0 * a);
}

이렇게 코드를 바꿔보자

discriminant의 반환타입을 bool에서 double로 수정함

discriminant만 return을 할때 구와 접점이 없는 Ray에 대해서는 그냥 -1을 return하고,
접점이 있는 Ray에 대해서 근의 공식을 사용하여 근을 찾음

근의 공식은 근이 2개가 될 수 있는데?

이게 핵심임
우리가 물체를 바라볼때 상황에 따라서 물체 뒷면을 볼 수 있지?
예를들어 투명한 페트병같은거 ㅇㅇ

이런곳에서는 근의 공식을 이용했을때 근 2개가 나오는 경우 모두 사용해야함
그리고 이런 경우는 추가로 alpha값을 계산해서 뒷쪽은 좀 더 불투명하도록 계산을 해야함

하지만 위의 코드는 단순이 먼저 구와 Ray가 만나는 접점, 즉 가장 카메라에서 가장 가까운 접점이 반환되는거임

그러니 구의 앞면 부분과 생기는 접점만 반환되고, 뒷면은 무시하는 코드인거임ㅇㅇ

Color ray_color(const Ray& r)
{
    double t = hit_sphere(Point3(0, 0, -1), 0.5, r);
    if (t > 0.0) {
        // 법선 벡터 N을 단위 길이 벡터로 생성
        Vector3 N = unit_vector(r.at(t) - Vector3(0, 0, -1));
        // 법선 벡터 N의 각 구성요소들을 -1 ~ 1 범위에서 0 ~ 1 범위로 매핑
        return 0.5 * Color(N.x() + 1, N.y() + 1, N.z() + 1);
    }

    Vector3 unit_dir = unit_vector(r.direction());

    //unit_dir은 정규화된 벡터로 -1~1사이의 값을 가짐
    //하지만 색상에 -1~0사이의 값은 없음
    //따라서 이를 0~1로 정규화해줘야함
    //그게 (unit_dir + 1) / 0.5인거임
    //-1일때 : (-1 + 1) / 0.5 = 0
    //1일때 : (1 + 1) / 0.5 = 1
    //즉, -1일때는 최하단으로 흰색
    //0일때는 중간으로 (1,1,1)과 (0.5,0.7,1.0)의 중간 혼합색(선형 블렌딩)
    //1일때는 최상단으로 (0.5,0.7,1.0)색
    t = 0.5 * (unit_dir.y() + 1.0);

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

이제 ray_color메서드를 수정해야하는데,

  1. ray의 광선과 방향을 기준으로 구체와 접점이 생기는 부분을 찾음: t > 0.0

  2. 조건에 해당되는 부분은 구체와 접점이 있다는 뜻이므로, 해당 접점을 구의 중심으로부터 Normal을 찾음

    이게 무슨소리냐면...


    구가 있고, 구의 중심에서 접점까지까지의 벡터는 수직벡터임

    접점에서 구의 중심을 빼면 중심에서 해당 접점으로의 방향을 가지는 벡터가 나옴

    • 이때 t는 Ray시작 지점에서부터 접점까지의 이동거리(스칼라)임
    • Ray에서 t만큼의 이동한 벡터와 원의 중심좌표를 빼면, 원 중심에서부터 t만큼 이동한 거리에 있는 벡터의 Normal이 만들어지고, 이를 unit_vector를 이용해 정규화 시킴(계산이 쉽도록)
Point3 at(double t) const 
{
    return orig + t * dir;
}
  1. 해당 법선벡터의 정규화된 값은 -1~1사이의 값이므로 0~1사이의 값으로 만들어주기위해 모든 요소에 +1을 하고 0.5를 곱한다

그럼 아래와 같은 이미지가 만들어짐

이런 값이 나오는건 xyz, rgb에 따른 결과인거임

Ray가 발사된 카메라에서 살펴보면 구체의 정중앙 부분은 카메라와 가장 가까운 면이 될거임

우리가 계산한 식은 모두 Ray를 기준으로 계산된거임, 빛이 아직은 없는거임

정중앙은 Ray와 z축으로 수직이므로 (0,0,1)이됨
이를 정규화과정을 거치면 아래와 같은 색상이 됨

우측은 Ray를 기준으로 x축으로 수직임 (1,0,0)
따라서 우측의 rgb는 1, 0.5, 0.5임

위쪽은 Ray를 기준으로 y축으로 수직임 (0,1,0)
따라서 위쪽의 rgb는 0.5, 1, 0.5임

왼쪽은 Ray를 기준으로 x축으로 수직이지만 방향이 반대임(-1, 0, 0)
따라서 왼쪽의 rgb는 0, 0.5, 0.5가됨

아래쪽은 Ray를 기준으로 y축으로 수직이지만 방향이 반대임 (0, -1, 0)
따라서 아래쪽의 rgb는 0.5, 0, 0.5임

코드 최적화

double hit_sphere(const Point3& center, double radius, const Ray& r)
{
    Vector3 oc = center - r.origin();
    double a = dot(r.direction(), r.direction());
    double b = -2.0 * dot(r.direction(), oc);
    double c = dot(oc, oc) - radius * radius;
    double discriminant = b * b - 4 * a * c;

    if (discriminant < 0)
    {
        return -1.0;
    }
    return (-b - sqrt(discriminant)) / (2.0 * a);
}

이 코드를 잘 살펴보자

1. 자기자신 벡터와의 내적은 자기자신 벡터의 제곱과 같음

먼저 double a, double c부분을 보자

  1. r.directionr.direction을 내적하고 있음
  2. ococ를 내적하고 있음

자신 벡터와의 내적은 자신 벡터의 제곱과 같음
따라서 아래처럼 코드를 수정가능함

double hit_sphere(const Point3& center, double radius, const Ray& r)
{
    Vector3 oc = center - r.origin();
    //자기자신 벡터와의 내적은 자기자신 벡터의 제곱과 같음
    double a = r.direction().length_squared();
    double b = -2.0 * dot(r.direction(), oc);
     //자기자신 벡터와의 내적은 자기자신 벡터의 제곱과 같음
    double c = oc.length_squared() - radius * radius;
    double discriminant = b * b - 4 * a * c;

    if (discriminant < 0)
    {
        return -1.0;
    }
    return (-b - sqrt(discriminant)) / (2.0 * a);
}

2. 근의 공식 최적화

두번째는 근의 공식 자체를 수정하는거임

b±b24ac2a\frac{-b \pm \sqrt{b^2 - 4ac}}{2a} 가 근의 공식이지?

만약 이때 b가 어떤 수의 -2의 배수라고 가정을 해보셈

즉 half라는 수가 있고 b = -2h라고 가정을 하는거임

그럼
(2h)±(2h))24ac2a=2h±4(h2ac)2a=2h±2h2ac2a=h±h2aca\frac{-(-2h) \pm \sqrt{(-2h))^2 - 4ac}}{2a} = \frac{2h \pm \sqrt{4(h^2 - ac)}}{2a} = \frac{2h \pm 2\sqrt{h^2 - ac}}{2a} = \frac{h \pm \sqrt{h^2 - ac}}{a}
이렇게 더 간단하게 식 유도가 됨


원래 식에서 생각해보면
b=2d(CQ)b = -2d \cdot (C - Q) 였음
b=2hb = -2h이므로 2h=2d(CQ)-2h = -2d \cdot (C - Q)와 같음
h=d(CQ)h = d \cdot (C - Q)가 됨

따라서 hit_sphere메서드를 더 가볍게 최적화 가능함

double hit_sphere(const Point3& center, double radius, const Ray& r)
{
    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 -1.0;
    }
    return (half_b - sqrt(discriminant)) / a;
}

이렇게 바뀌는거임

구체 여러개 그리기

먼저 코사인에 대해 집고 넘어가자

중학교때 배운 코사인 알지?

각도 (Degree)라디안 (Radian)코사인 값 (cosθ)소수점 값 (근사치)
011.000
30°π/6√3/20.866
45°π/4√2/20.707
60°π/31/20.500
90°π/200.000
120°2π/3-1/2-0.500
135°3π/4-√2/2-0.707
150°5π/6-√3/2-0.866
180°π-1-1.000
210°7π/6-√3/2-0.866
225°5π/4-√2/2-0.707
240°4π/3-1/2-0.500
270°3π/200.000
300°5π/31/20.500
315°7π/4√2/20.707
330°11/π/6√3/20.866
360°11.000

즉, 두 벡터의 내적의 결과가
0이면 두 벡터는 수직
1이면 두 벡터는 완전 같은 방향(겹침)
-1이면 두 벡터는 완전 반대 방향(정면으로 마주봄)

Ray에서 구체로 쏘아지는 광선 계산

이걸 Ray와 구의 접점에 대해 개념을 옮겨보자

  1. Ray가 구 외부에서 구 내부로 들어갈때

Ray의 시작점에서 구 내부: ⬇️
Ray와 구의 법선: ⬆️

즉 Ray와 Normal은 서로 바라보는 방향, 완전히 다른 방향이므로 0보다 작은 값(음수)이 된다.

  1. Ray가 구 내부에서 구 외부로 나갈때

Ray의 시작점에서 구 외부: ⬆️
Ray와 구의 법선: ⬆️

즉 Ray와 Normal은 서로 같은 방향, 완전히 같은 방향이므로 0보다 큰 값(양수)이 된다.

이것이 outward_normal

1번일때 Normal은 구 중심에서 접점으로의 방향에서 내적은 0보다 큰 값(양수)
Center,Point\overrightarrow{Center ,Point}: ⬆️
Ray와 구의 법선: ⬆️

2번일때 Normal은 구 중심에서 접점으로의 방향에서 내적은 0보다 작은 값(음수)
Center,Point\overrightarrow{Center ,Point}: ⬆️
Ray와 구의 법선: ⬇️

이게 inward_normal

이 중 편한걸 하면 됨

대신 값 계산은 잘 해야지ㅇㅇ

outward_normal을 이용해서 Ray와 법선의 관계를 판단하게뜸

핵심은 뒷면은 보이지 않도록 한다는거임
아래 코드처럼 Hittable.h를 만듬

#ifndef HITTABLE_H
#define HITTABLE_H

#include "Ray.h"

class HitRecord
{
public:
    Point3 p;
    Vector3 normal;
    double t;
    bool front_face;

    void set_face_normal(const Ray& r, const Vector3& outward_normal)
    {
        // 1. 광선(Ray)과 밖을 향하는 법선(outward_normal)의 내적이 음수(< 0.0)인지 확인합니다.
        // 내적이 음수면: 광선이 밖에서 안으로 들어오는 중 (앞면, front_face = true)
        // 내적이 양수면: 광선이 안에서 밖으로 나가는 중 (뒷면, front_face = false)
        front_face = dot(r.direction(), outward_normal) < 0.0;

        // 2. 항상 법선이 광선과 마주 보게(내적이 음수가 되게) 방향을 통일해 줍니다.
        // 앞면을 때렸다면 밖을 향하는 법선을 그대로 사용하고,
        // 뒷면(물체 내부)을 때렸다면 법선을 뒤집어서(-outward_normal) 사용합니다.
        normal = front_face ? outward_normal : -outward_normal;
    }
};

class Hittable
{
public:
    virtual ~Hittable() = default;
    
    virtual bool hit(const Ray& r, double ray_tmin, double ray_tmax, HitRecord& hitRec) const = 0;
};

#endif

그리고 sphere.h를 만듬

#ifndef SPHERE_H
#define SPHERE_H
#include "hittable.h"

class Sphere : public Hittable
{
private:
    Point3 center;
    double radius;
public:
    Sphere();
    Sphere(Point3 center, double r) : center(center), radius(r) {};
    
    bool hit(const Ray& r, double ray_tmin, double ray_tmax, 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_tmax <= root || root <= ray_tmin)
        {
            root = (half_b + sqrtDis) / a;
            if (ray_tmax <= root || root <= ray_tmin)
            {
                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;
    };
};


#endif

구체 여러개 관리하기

HittableList를 만들어서 관리할거임

#ifndef HITTABLE_LIST_H
#define HITTABLE_LIST_H
#include <vector>

#include "Hittable.h"

using namespace std;

class HittableList : public Hittable
{
public:
    vector<shared_ptr<Hittable>> objs;

    HittableList();
    HittableList(shared_ptr<Hittable> obj) { add(obj); }
    
    void clear() { objs.clear(); }
    void add(shared_ptr<Hittable> obj)
    {
        objs.push_back(obj);
    }

    bool hit(const Ray& r, double ray_tmin, double ray_tmax, HitRecord& hitRec) const override
    {
        HitRecord tempRec;
        bool doesHitAnything = false;
        double closestSoFar = ray_tmax;

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

        return doesHitAnything;
    }
};

#endif

shared_ptr은 cpp의 스마트포인터임
그냥 new 와 delete를 적절하게 알아서 잘 해준다고만 알고 넘어가셈

더 궁금하면 cpp smart pointer라는 개념으로 찾기 ㄱㄱ

계산 쎄리기

#ifndef RTWEEKEND_H
#define RTWEEKEND_H

#include <cmath>
#include <iostream>
#include <limits>
#include <memory>


// C++ Std Usings

using std::make_shared;
using std::shared_ptr;

// Constants

const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;

// Utility Functions

inline double degrees_to_radians(double degrees) {
    return degrees * pi / 180.0;
}

// Common Headers

#include "color.h"
#include "ray.h"
#include "vec3.h"

#endif

먼저 이런 Util클래스를 만들어줌

그리고 main을 수정하자

Color ray_color(const Ray& r, const Hittable& world) {
    HitRecord rec;
    if (world.hit(r, 0, infinity, rec)) {
        return 0.5 * (rec.normal + Color(1, 1, 1));
    }
    
    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);
}

int main()
{
    auto aspect_ratio = 16.0 / 9.0;
    int image_width = 400;
    
    //height  = width / 16 * 9 = width * 9 / 16 = width / (16 / 9)
    int image_height = int(image_width / aspect_ratio);
    //height가 1보다 작으면 최소한 1이라도 보장하기
    image_height = (image_height < 1) ? 1 : image_height;

    //world
    HittableList world;
    world.add(make_shared<Sphere>(Point3(0, 0, -1), 0.5));
    world.add(make_shared<Sphere>(Point3(0, -100.5, -1), 100));

    // Camera
    //카메라부터 뷰표트까지의 거리
    double focal_length = 1.0;
    double viewport_height = 2.0;
    //해상도 비율을 사용하지 않고, 이미지를 사용하는 이유
    //해상도 이미지는 이상적인 비율이고, 실제 이미지의 비율은 해상도 비율과 다를 수 있음
    //width = height / 9 * 16 = height * 16 / 9
    double viewport_width = viewport_height * (double(image_width) / image_height);
    Point3 camera_center = Point3(0, 0, 0);

    //뷰표트의 왼->오, 상->하로 이동하는 최대 좌표 구하기
    Vector3 viewport_u = Vector3(viewport_width, 0, 0);
    Vector3 viewport_v = Vector3(0, -viewport_height, 0);

    //뷰포트의 각 픽셀간의 거리 구하기(델타 벡터)
    Vector3 pixel_delta_u = viewport_u / image_width;
    Vector3 pixel_delta_v = viewport_v / image_height;

    //최 좌상단 픽셀의 좌표구하기
    //camera_center - Vector3(0, 0, focal_length) : 뷰포트 바로 위에서 focal_length만큼 z방향 반대로 떨어지기
    // - viewport_u/2 - viewport_v/2 : 화면의 가로세로 절반씩 좌, 상으로 이동하기(뺄셈)
    Vector3 viewport_upper_left = camera_center - Vector3(0, 0, focal_length) - viewport_u / 2 - viewport_v / 2;
    //위의 공식은 뷰포트의 최좌상단 좌표가 됨. 하지만 픽셀 시작 좌표는 아님
    //따라서 시작지점은 뷰포트 픽셀거리의 절반만큼 띄워진 거리에서부터 시작해야 정확히 너비 * 높이 개의 영역으로 균등하게 나눠짐
    Vector3 pixel00_loc = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);

    //render
    cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";

    for (int j = 0; j < image_height; ++j)
    {
        clog << "\rScanLines remaining: " << (image_height - j) << ' ' << flush;
        for (int i = 0; i < image_width; ++i)
        {
            Vector3 pixel_center = pixel00_loc + (i * pixel_delta_u) + (j * pixel_delta_v);
            Vector3 ray_dir = pixel_center - camera_center;
            Ray r = Ray(camera_center, ray_dir);

            //world인 hittableList를 이용해 색상계산
            Color pixel_color = ray_color(r, world);

            write_color(std::cout, pixel_color);
        }
    }

    clog << "\rDone.                                                     \n";
}

색이 계산되는 과정은 아래와 같음

  1. HittableList에 원 2개를 넣음
  2. ray_color를 통해 Hittable오브젝트의 hit을 실행함
  3. HittableList는 Hittable오브젝트이므로 HittableList를 인자값으로 넣어 HittableList.hit을 실행
  4. HittableList의 hit메서드 내부에서 for문을 돌며 List에 담긴 Hittable오브젝트 -> Sphere의 hit을 실행
  5. true가 반환되면 참조로 들어갔던 HitRecord객체의 normal을 이용해 색상계산 후 화면에 출력

이 됨

실행결과는 다음과 같음

추가로
https://raytracing.github.io/books/RayTracingInOneWeekend.html#surfacenormalsandmultipleobjects/anintervalclass
이것도 해주자

profile
그래픽스 공부중

0개의 댓글