Search

Anti-Aliasing

Created
2021/03/26
Tags
Chapter 7
실제로 우리가 카메라를 이용해서 사진을 찍게되면, 사진 내에 존재하는 Object의 가장자리에는 들쭉날쭉한 것들이 존재하지 않는다. 사진에는 현재 우리가 만들어낸 Image와 달리 들쭉날쭉한 것들이 존재하지 않는 이유는 가장자리의 Pixel에는 Object의 앞과 뒤를 적절하게 Blending하기 때문이다. 이런 Blending을 구현하는데 있어서 계층화를 이용할 수도 있고 이용하지 않을 수도 있지만, 현재 진행하는 코딩에서는 계층화를 이용하지 않을 것이다. 다른 몇 Ray Tracer에서는 계층화를 하지 않는 것이 치명적일 수 있지만, 우리가 작성하는 굉장히 포괄적인 Ray Tracer에서는 계층화를 하는 것에서 장점을 끌어낼 수 있는 부분이 대체로 없기 때문이다. (오히려 코드가 더러워질 수 있다.) 따라서 별도의 계층화 없이 들쭉날쭉한 가장자리를 처리하기 (Anti-Aliasing)에 앞 서, 카메라에 대한 Abstract Class를 간단히 먼저 정의해보자.

1. Some Random Number Utilities

Anti-Aliasing을 구현하는데 있어서 사전에 필요한 것은 난수 생성 모듈이다. C 언어에서 선형 합동 (Linear Congruential) 알고리즘을 통해 구현되었던 randsrand처럼 의사 난수 (Pseudo Random Number)를 이용하는 것이 아니라 말 그대로 0r<10 ≤ r < 1 범위를 갖는 진짜 난수가 필요하다.
난수를 얻을 때는 무조건 0r<10≤r<1의 범위로 생성되는 것은 아니므로 주어진 범위에 맞춰 변환해주어야 Anti-Aliasing을 구현할 때 득을 많이 볼 수 있다.

1) rand & srand

우선 진짜 난수를 얻는 방법에 대해서 알아보기 이전에 C 언어에서 사용했던 randsrand에 대한 함수부터 알아보자.
#include <stdio.h> #include <stdlib.h> #include <time.h> int main(void) { int i; srand(time(NULL)); for(i = 0; i < 5; i++) printf("random : %d\n", rand() % 100); return (0); }
C
위 코드에서 보이는 것은 진짜 난수가 아니라고 이전에 언급한 적이 있다. 코드 상에서 구한 난수는 의사 난수로써 난수처럼 보이는 수이다. 첫 번째 수만 무작위로 정해둔 뒤에 나머지 수들은 첫 번째 무작위 수를 기반으로 여러 수학적인 기법을 이용하여 난수처럼 보이지만 실제로는 난수가 아닌 수열을 만들게 된다. 따라서 처음에 무작위로 정해진 수를 Seed라고 부르게 되는데 Seed 값의 설정은 srand 함수가 수행해준다. 이 때 printf 구문에서 보이는 rand 함수는 srand 함수를 통해 설정된 Seed값을 기반으로 무작위처럼 보이는 수열을 생성한는 것이다. 의사 난수는 어떤 한계가 있길래 이를 이용하는 것보다 진짜 난수를 얻으려고 하는 것일까?

[1] 진짜 난수인 Seed 값의 변화가 느리다.

srand를 통해 Seed 값을 정하는 구문을 보면 현재의 초를 지정하여 Seed를 설정하였다. 설정된 Seed 값의 문제라 함은 현재의 초를 기준으로 생성된 것이므로 같은 기법으로 같은 시간대에 시작된 모든 프로그램은 모두 동일한 의사 난수 수열이 생성된다는 것이다. 여러 프로그램이 작동하는 시스템에서 srand를 통해 설정된 Seed를 이용하게 되면 같은 난수열을 생성하는 프로그램을 작동하게 된다는 것이다.

[2] 균등하게 난수를 생성하지 않는다. (Uniform Distribution을 따르지 않는다.)

Seed 값을 통해 rand 함수를 이용하여 생성된 의사 난수는 0부터 RAND_MAX의 범위를 갖는다. rand 함수는 0부터 RAND_MAX까지의 Uniform Distribution을 따르기 때문에 함수의 반환으로 얻은 값은 동일한 확률에서 얻은 값이지만, Modulo를 통해 얻어낸 값이 Uniform Distribution을 따르지는 않는다. RAND_MAX128로 설정되어 있고 위와 같이 100으로 Modulo를 수행했을 때, 1rand로 부터 1 혹은 101로부터 생성이 되지만, 50rand로부터 50이 생성된 경우에만 그 결과를 볼 수 있다. 따라서 이 경우에는 1이 뽑힐 확률이 50이 뽑힐 확률보다 2배나 높다는 것이다.

[3] rand 함수의 성능이 뛰어나지 않다.

난수로 생성된 Seed 값을 기반으로 수학적인 기법을 이용하여 난수처럼 보이는 수열을 만든다고 했는데, 여기서 사용되는 기법이 선형 합동이라는 알고리즘을 기반으로 한다. rand 함수에서 사용되는 선형 합동 알고리즘은 난수열 생성에 대해 그닥 좋은 품질을 보장하지 않는다. (생성되는 난수열 간에 상관 관계가 높은 편이라 좋은 품질이 아니라는 것이다.)
그렇다면 C++에서는 srandrand의 한계를 벗어났을까?

2) <random> of C++

[1] random_device

// from <random> std::random_device rd;
C++
C 언어에서의 Seed 값 설정은 srand 함수의 인자로 time(NULL)을 주어 사용했지만 이에 대해서 문제가 있었다. C++에서는 양질의 Seed 값을 얻기 위해서 random_device라는 객체를 이용한다.
대체적으로 운영체제에서는 진짜 난수를 얻어낼 수 있는 여러 방법들을 제공한다. 예를 들어 리눅스의 경우에는 /dev/random 혹은 /dev/urandom이라고 하는 파일을 통해서 난수를 얻을 수 있다. 여기서 얻어진 난수 값은 수학적인 기법을 이용하여 생성된 난수 값이 아니라 실제 컴퓨터가 동작하면서 마주하는 무작위 요소들을 통해 얻은 난수이다. (예를 들면 각 컴퓨터 하드웨어의 Noise와 같은 것들이 있다.)
C++에서의 random_device라는 객체가 바로 위에서 소개한 운영체제에서 제공하는 진짜 난수를 이용하도록 도와준다. 다만 난수 발생 과정을 생각해봤을 때, 위에서 얻은 진짜 난수는 컴퓨터가 주변 환경과의 상호 작용을 통해서 얻어진 것이기 때문에 의사 난수처럼 계산을 통해 생성되는 난수보다 얻는데 시간이 꽤 걸린다. 따라서 random_device를 통해 얻을 수 있는 난수는 일반적으로 난수 엔진을 초기화하는 Seed로써 사용한다. random_device를 통해 난수의 Seed를 얻었다면, 사용하려고 하는 난수는 난수 엔진으로 생성한다고 보면 된다.

[2] mt19937

// from <random> std::mt19937 gen(rd());
C++
이전에 생성해둔 random_device 객체 rd를 이용하여 난수 엔진 객체의 인자로써 사용할 수 있다. 만일 random_device 객체로부터 얻은 난수 값이 아닌 사용자가 직접 설정한 값으로 Seed를 이용하고 싶다면 그렇게 해도 된다.
위에서 사용하고 있는 난수 엔진std::mt19937이라는 것으로 C++random 라이브러리에서 제공하는 난수 엔진 중에 하나이다. 이름이 mt19937인 이유는 난수 생성 알고리즘으로 메르센 트위스터라는 알고리즘을 이용하기 때문이다. 메르센 트위스터 알고리즘은 기존의 선형 합동 알고리즘보다 더 양질의 난수열을 생성한다. (난수 간의 상관 관계가 굉장히 작다.)
random_device진짜 난수를 얻어오는데 시간이 다소 걸린다는 문제가 있었듯이, mt19937 역시 객체를 하나 생성하는데 메모리를 꽤나 소요하는 문제가 있다. 객체 하나의 크기가 2KB 이상이라 메모리가 부족한 시스템에서는 선호되지 않는 경향이 있다. 또한 난수 생성 작업 자체는 빠른 편이나 객체의 크기가 크다보니 처음에 객체를 생성하는데 시간이 조금 소요되는 편이다.
메모리가 부족한 시스템에서는 rand 함수처럼 선형 합동 알고리즘으로 만들어진 minstd_rand라는 난수 엔진을 이용한다. 이 난수 엔진 역시 random 라이브러리에서 제공하며, rand 함수보다는 나은 성능을 보장한다.

[3] uniform_real_distribution

// from <random> std::uniform_real_distribution<double> dist(0.0, 1.0);
C++
random_device를 통해 난수 Seed를 얻어 mt19937이라는 난수 엔진을 얻었다면, 난수를 생성할 준비가 끝났다. C++random 라이브러리를 이용하여 난수를 생성하고 싶다면, 난수 엔진난수의 분포가 필요하다. 지금까지는 난수 엔진을 얻었으므로 난수의 분포만 정의하면 된다는 것이다. 우리가 알고 있는 다양한 분포를 이용할 수도 있지만, 현 과정에서는 각 난수가 동일한 확률로 생성되길 바라기 때문에 Uniform Distribution을 이용하겠다.
확률 및 랜덤 프로세스에 대해 이해하고 있다면 난수의 분포가 다양하다는 것을 알고 있을 것이다. random 라이브러리 내에는 다양한 분포가 구현되어 있어 이를 이용할 수 있다. 예를 들어, Normal Distribution도 존재한다.
난수를 생성할 때는 아래와 같이 난수의 분포 객체의 인자로 난수 엔진을 주는 식으로 이용하면 된다.
dist(gen);
C++

3) rtweekend.h 수정

Seed, 난수 엔진, 난수의 분포에 대해서 배웠기 때문에 C 언어에서 제공하는 randsrand를 피해 rtweekend.h난수 생성 모듈을 구현할 수 있다. 아래와 같이 수정한다.
#ifndef RTWEEKEND_H # define RTWEEKEND_H # include <cmath> # include <limits> # include <memory> # include <random> // Usings using std::shared_ptr; using std::make_shared; using std::sqrt; // 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); } // Random Generating inline double random_double() { // Returns a random real in [0, 1). static std::random_device rd; static std::mt19937 gen(rd()); static std::uniform_real_distribution<double> dist(0.0, 1.0); return (dist(gen)); } inline double random_double(double min, double max) { // Returns a random real in [min, max). static std::random_device rd; static std::mt19937 gen(rd()); static std::uniform_real_distribution<double> dist(0.0, 1.0); return (min + (max - min) * dist(gen)); } // Utility inline double clamp(double x, double min, double max) { if (x < min) return (min); if (x > max) return (max); return (x); } // Common Headers # include "ray.h" # include "vec3.h" #endif
C++

2. Generating Pixels with Multiple Samples

기존의 주어진 Pixel에 대해서 처리할 때는 여러 Sample을 두지 않고 그저 Pixel의 정중앙에 하나의 Sample을 두어 Sampleray가 닿으면 발생하는 색상을 그대로 Pixel의 색상으로 이용했다. 결과적으로 Object의 가장자리가 들쭉날쭉한 현상을 볼 수 있었고, 이를 방지하는 Anti-Aliasing에는 여러 방법이 있다. 우리는 Pixel에 여러 Sample을 두어 Sample들이 ray를 만났을 때 색상 값들에 대해 평균을 계산하여 Pixel의 색상을 결정할 것이다.
위와 같이 처리하기 위해서 기존에 main.cpp에 작성되었던 코드를 활용하여 축에 정렬된 상태의 간단한 camera라는 Class를 만들어 ScreenSampling을 쉽게 관리할 수 있도록 할 것이다. camera.h는 다음과 같다.
#ifndef CAMERA_H # define CAMERA_H # include "rtweekend.h" class camera { public: camera() { auto aspect_ratio = 16.0 / 9.0; auto viewport_height = 2.0; auto viewport_width = viewport_height * aspect_ratio; auto focal_length = 1.0; origin = point3(0, 0, 0); horizontal = vec3(viewport_width, 0, 0); vertical = vec3(0, viewport_height, 0); lower_left_corner = origin - horizontal / 2 - vertical / 2 - vec3(0, 0, focal_length); } ray get_ray(double u, double v) const { return ray(origin, lower_left_corner + u * horizontal + v * vertical - origin); } private: point3 origin; point3 lower_left_corner; vec3 horizontal; vec3 vertical; }; #endif
C++
여러 Sample들을 통해 계산되는 색상을 관리하기 위해선 color.h에 작성했던 write_color라는 함수도 수정해야 한다. 기존 write_color는 단순히 Pixel의 한 색상을 받아와서 처리를 했다면, 이번에는 여러 Sample들의 색상을 처리할 수 있도록 변경해야 한다. Sample의 수만큼 write_color 함수를 반복 호출하여 Sample 처리마다 색상을 바로 반영을 할 수도 있겠지만, 그 방법 보다는 모든 Sample의 색상을 반복을 통해 누적 받아 write_color 함수 한 번의 호출로 평균치를 구하는 방식으로 작성할 것이다. 이 때 누적 받은 색상의 값을 Sample의 수만큼 나누면서 평균치를 구하게 되는데 이 과정을 Scaling이라고 한다. Scaling을 진행할 때, 그 값이 0.01.0사이의 값을 가져야하기 때문에 범위를 넘어간 값에 대해서 처리를 해줘야 한다 이를 Clamp라고 한다. 즉, 기존의 write_color 함수에서 ScalingClamp를 처리하여 색상을 출력하도록 바꿔주면 되는 것이다. 수정된 color.h의 코드와 rtweekend.h의 코드는 아래와 같다.
#ifndef COLOR_H # define COLOR_H # include "rtweekend.h" # include <iostream> void write_color(std::ostream &out, color pixel_color, int samples_per_pixel) { auto r = pixel_color.x(); auto g = pixel_color.y(); auto b = pixel_color.z(); // Divide the color by the number of samples. auto scale = 1.0 / samples_per_pixel; r *= scale; g *= scale; b *= scale; // Write the translated [0,255] value of each color component. out << static_cast<int>(255.999 * clamp(r, 0.0, 0.999)) << ' ' << static_cast<int>(255.999 * clamp(g, 0.0, 0.999)) << ' ' << static_cast<int>(255.999 * clamp(b, 0.0, 0.999)) << '\n'; } #endif
C++
#ifndef RTWEEKEND_H # define RTWEEKEND_H # include <cmath> # include <limits> # include <memory> # include <random> // Usings using std::shared_ptr; using std::make_shared; using std::sqrt; // 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); } // Random Generating inline double random_double() { // Returns a random real in [0, 1) static std::random_device rd; static std::mt19937 gen(rd()); static std::uniform_real_distribution<double> dist(0.0, 1.0); return (dist(gen)); } // Utility inline double clamp(double x, double min, double max) { if (x < min) return (min); if (x > max) return (max); return (x); } // Common Headers # include "ray.h" # include "vec3.h" #endif
C++
위와 같이 색상 출력에 대해서도 변경 했다면, main.cpp에서 임의의 Sample들을 정하고 해당 Sample들의 색상 값을 누적 받는 반복과 색상 값을 write_color로 넘기는 부분을 작성하기만 하면 된다. 수정된 main.cpp는 아래와 같다.
#include "rtweekend.h" #include "color.h" #include "hittable_list.h" #include "sphere.h" #include "camera.h" #include <iostream> color ray_color(const ray& r, const hittable& world) { hit_record rec; if (world.hit(r, 0, infinity, rec)) return (0.5 * (rec.normal + color(1, 1, 1))); vec3 unit_direction = unit_vector(r.direction()); auto 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(void) { // Image const auto aspect_ratio = 16.0 / 9.0; const int image_width = 400; const int image_height = static_cast<int>(image_width / aspect_ratio); const int samples_per_pixel = 100; // World hittable_list world; world.add(make_shared<sphere>(point3(0, 0, -1), 0.5)); world.add(make_shared<sphere>(point3(0, -100.5, -1), 100)); // Camera camera cam; // Render std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n"; for (int j = image_height - 1; j >= 0; --j) { std::cerr << "\rScanlines Remaining: " << j << ' ' << std::flush; for (int i = 0; i < image_width; ++i) { color pixel_color(0, 0, 0); for (int s = 0; s < samples_per_pixel; ++s) { auto u = (i + random_double()) / (image_width - 1); auto v = (j + random_double()) / (image_height - 1); ray r = cam.get_ray(u, v); pixel_color += ray_color(r, world); } write_color(std::cout, pixel_color, samples_per_pixel); } } std::cerr << "\nDone.\n"; return (0); }
C++
수정된 코드를 실행해보면 아래 사진과 같이 이전과 다르게 Object의 가장자리가 들쭉날쭉하지 않게 Anti-Aliasing이 된 것을 확인할 수 있다.