Diffuse Materials
이제 객체들과 픽셀당 다중 광선이 있으므로, 사실적으로 보이는 재질을 만들 수 있습니다. 확산 재질(무광택이라고도 함)부터 시작하겠습니다. 한 가지 의문점은 기하학과 재질을 분리할 것인지(재질을 여러 구체에 할당하거나 그 반대로 할 수 있도록), 아니면 밀접하게 결합할 것인지(기하학과 재질이 연결된 절차적 객체에 유용할 수 있음)입니다. 우리는 분리된 방식을 선택할 것입니다—이것이 대부분의 렌더러에서 일반적입니다—하지만 대안적인 접근 방식도 있다는 것을 알아두시기 바랍니다.
A Simple Diffuse Material
자체 빛을 방출하지 않는 확산 객체는 주변의 색상을 띠지만, 고유한 색상으로 이를 조절합니다. 확산 표면에서 반사되는 빛은 방향이 무작위화됩니다. 따라서 두 개의 확산 표면 사이의 틈새로 세 개의 광선을 보내면 각각 다른 무작위 동작을 보일 것입니다:
*그림 9: 광선의 반사*
광선은 반사되지 않고 흡수될 수도 있습니다. 표면이 어두울수록 광선이 흡수될 가능성이 높습니다(그래서 어두운 것입니다!). 방향을 무작위화하는 모든 알고리즘은 무광택으로 보이는 표면을 생성할 것입니다. 가장 직관적인 것부터 시작해 봅시다: 광선을 모든 방향으로 균등하게 무작위로 반사시키는 표면입니다. 이 재질의 경우, 표면에 닿은 광선은 표면에서 멀어지는 모든 방향으로 반사될 확률이 동일합니다.
*그림 10: 수평선 위의 균등한 반사*
이 매우 직관적인 재질은 가장 단순한 종류의 확산이며 — 실제로 — 초기 레이트레이싱 논문들 중 많은 수가 이 확산 방법을 사용했습니다(우리가 조금 후에 구현할 더 정확한 방법을 채택하기 전에). 현재 우리는 광선을 무작위로 반사시킬 방법이 없으므로, 벡터 유틸리티 헤더에 몇 가지 함수를 추가해야 합니다. 우리가 필요한 첫 번째 것은 임의의 무작위 벡터를 생성하는 기능입니다:
class Vec3
{
public:
...
double LengthSquared() const
{
return mElements[0] * mElements[0]
+ mElements[1] * mElements[1]
+ mElements[2] * mElements[2];
}
static Vec3 Random()
{
return Vec3(RandomDouble(), RandomDouble(), RandomDouble());
}
static Vec3 Random(double minimum, double maximum)
{
return Vec3(
RandomDouble(minimum, maximum),
RandomDouble(minimum, maximum),
RandomDouble(minimum, maximum)
);
}
private:
double mElements[3] = {};
};
C++
복사
*리스팅 47: [vec3.h] vec3 무작위 유틸리티 함수*
다음으로, 반구 표면에 있는 벡터만 얻도록 무작위 벡터를 조정하는 방법이 필요합니다. 분석적 방법이 존재하지만, 이해하기 어렵고 구현도 복잡합니다. 대신, 가장 간단한 알고리즘인 기각 방법(rejection method)을 사용하겠습니다. 기각 방법은 원하는 기준을 충족하는 샘플을 찾을 때까지 무작위 샘플을 반복 생성합니다. 즉, 좋은 샘플을 찾을 때까지 나쁜 샘플을 계속 버리는 방식입니다.
기각 방법으로 반구 위의 무작위 벡터를 만드는 방법은 여러 가지가 있는데, 여기서는 가장 간단한 방법을 사용하겠습니다:
1.
단위 구 안에 무작위 벡터를 만듭니다
2.
이 벡터를 정규화해서 구 표면까지 늘립니다
3.
반구의 반대쪽에 있으면 정규화된 벡터를 뒤집습니다
먼저 기각 방법으로 단위 구 안에 무작위 벡터를 만듭니다(반지름이 1인 구). 단위 구를 감싸는 정육면체 안에서 무작위 점을 고릅니다(x, y, z가 모두 [−1,+1] 범위에 있음). 이 점이 단위 구 밖에 있으면, 단위 구 안이나 표면에 있는 점을 찾을 때까지 계속 새로운 점을 만듭니다.
*그림 11: 좋은 벡터를 찾기 전에 두 개의 벡터가 기각되었습니다(정규화 이전)*
*그림 12: 선택된 무작위 벡터는 단위 벡터를 생성하기 위해 정규화됩니다*
다음은 함수의 첫 번째 초안입니다:
...
inline Vec3 UnitVector(const Vec3& v)
{
return v / v.Length();
}
inline Vector3 RandomUnitVector()
{
while (true)
{
auto p = Vector3::Random(-1.0, 1.0);
auto lengthSquared = p.LengthSquared();
if (lengthSquared <= 1.0)
{
return p / std::sqrt(lengthSquared);
}
}
}
C++
복사
*리스팅 48: [vec3.h] random_unit_vector() 함수, 버전 1*
안타깝게도, 처리해야 할 작은 부동소수점 추상화 누수(abstraction leak)가 있습니다. 부동소수점 숫자는 정밀도가 유한하므로, 매우 작은 값이 제곱될 때 언더플로우되어 0이 될 수 있습니다. 세 좌표가 모두 충분히 작으면(구의 중심에 매우 가까우면), 벡터의 노름(norm)이 0이 되고, 정규화하면 잘못된 벡터 [±∞,±∞,±∞]가 생성됩니다. 이를 수정하기 위해, 중심 주변의 이 "블랙홀" 안에 있는 점들도 기각할 것입니다. 배정밀도(64비트 부동소수점)를 사용하면 10⁻¹⁶⁰보다 큰 값을 안전하게 지원할 수 있습니다.
다음은 더 견고한 함수입니다:
inline Vec3 RandomUnitVector()
{
while (true)
{
auto p = Vec3::Random(-1.0, 1.0);
auto lengthSquared = p.LengthSquared();
if (1e-160 < lengthSquared && lengthSquared <= 1.0)
{
return p / std::sqrt(lengthSquared);
}
}
}
C++
복사
*리스팅 49: [vec3.h] random_unit_vector() 함수, 버전 2*
이제 무작위 단위 벡터가 있으므로, 표면 법선과 비교하여 올바른 반구에 있는지 확인할 수 있습니다:
*그림 13: 법선 벡터가 필요한 반구를 알려줍니다*
표면 법선과 무작위 벡터의 내적을 구하여 올바른 반구에 있는지 확인할 수 있습니다. 내적이 양수이면 벡터가 올바른 반구에 있는 것입니다. 내적이 음수이면 벡터를 반전시켜야 합니다.
...
inline Vec3 RandomUnitVector()
{
while (true)
{
auto p = Vec3::Random(-1.0, 1.0);
auto lengthSquared = p.LengthSquared();
if (1e-160 < lengthSquared && lengthSquared <= 1.0)
{
return p / std::sqrt(lengthSquared);
}
}
}
inline Vector3 RandomOnHemisphere(const Vector3& normal)
{
Vector3 unitSphereDirection = RandomUnitVector();
if (Dot(unitSphereDirection, normal) > 0.0) // In the same hemisphere as the normal
{
return unitSphereDirection;
}
return -unitSphereDirection;
}
C++
복사
*리스팅 50: [vec3.h] random_on_hemisphere() 함수*
광선이 재질에서 반사되어 색상을 100% 유지하면, 그 재질을 흰색이라고 합니다. 광선이 재질에서 반사되어 색상을 0% 유지하면, 그 재질을 검은색이라고 합니다. 새로운 확산 재질의 첫 번째 시연으로, ray_color 함수가 반사된 색상의 50%를 반환하도록 설정하겠습니다. 멋진 회색을 얻을 수 있을 것으로 기대됩니다.
class Camera
{
...
private:
...
Color RayColor(const Ray& ray, const Hittable& world) const
{
HitRecord hitRecord;
if (world.Hit(ray, Interval(0.0, Infinity), hitRecord))
{
Vec3 direction = RandomOnHemisphere(hitRecord.normal);
return 0.5 * RayColor(Ray(hitRecord.point, direction), world);
}
Vec3 unitDirection = UnitVector(ray.Direction());
auto a = 0.5 * (unitDirection.Y() + 1.0);
return (1.0 - a) * Color(1.0, 1.0, 1.0)
+ a * Color(0.5, 0.7, 1.0);
}
};
C++
복사
*리스팅 51: [camera.h] 무작위 광선 방향을 사용하는 ray_color()*
... 실제로 꽤 멋진 회색 구들을 얻게 됩니다:
*이미지 7: 확산 구의 첫 번째 렌더링*
Limiting the Number of Child Rays
여기에 잠재적인 문제가 하나 숨어있습니다. ray_color 함수가 재귀적이라는 점에 주목하세요. 언제 재귀를 멈출까요? 아무것도 맞히지 못했을 때입니다. 하지만 어떤 경우에는 그 시간이 매우 길 수 있습니다 - 스택을 날려버릴 만큼 충분히 길 수도 있습니다. 이를 방지하기 위해 최대 재귀 깊이를 제한하여, 최대 깊이에서는 빛의 기여도를 반환하지 않도록 하겠습니다:
class Camera
{
public:
double aspectRatio = 1.0; // Ratio of image width over height
int imageWidth = 100; // Rendered image width in pixel count
int samplesPerPixel = 10; // Count of random samples for each pixel
int maxDepth = 10; // Maximum number of ray bounces into scene
void Render(const Hittable& world)
{
Initialize();
std::cout << "P3\n" << imageWidth << ' ' << mImageHeight << "\n255\n";
for (int scanlineIndex = 0; scanlineIndex < mImageHeight; scanlineIndex++)
{
std::clog
<< "\rScanlines remaining: "
<< (mImageHeight - scanlineIndex)
<< ' '
<< std::flush;
for (int pixelIndex = 0; pixelIndex < imageWidth; pixelIndex++)
{
Color pixelColor(0.0, 0.0, 0.0);
for (int sampleIndex = 0; sampleIndex < samplesPerPixel; sampleIndex++)
{
Ray ray = GetRay(pixelIndex, scanlineIndex);
pixelColor += RayColor(ray, maxDepth, world);
}
WriteColor(std::cout, mPixelSamplesScale * pixelColor);
}
}
std::clog << "\rDone. \n";
}
private:
Color RayColor(const Ray& ray, int depth, const Hittable& world) const
{
// If we've exceeded the ray bounce limit, no more light is gathered
if (depth <= 0)
{
return Color(0.0, 0.0, 0.0);
}
HitRecord hitRecord;
if (world.Hit(ray, Interval(0.0, Infinity), hitRecord))
{
Vec3 direction = RandomOnHemisphere(hitRecord.normal);
return 0.5 * RayColor(Ray(hitRecord.point, direction), depth - 1, world);
}
Vec3 unitDirection = UnitVector(ray.Direction());
auto a = 0.5 * (unitDirection.Y() + 1.0);
return (1.0 - a) * Color(1.0, 1.0, 1.0)
+ a * Color(0.5, 0.7, 1.0);
}
void Initialize()
{
...
}
Ray GetRay(int pixelIndex, int scanlineIndex) const
{
...
}
private:
int mImageHeight = 0;
double mPixelSamplesScale = 1.0;
};
C++
복사
*리스팅 52: [camera.h] 깊이 제한이 있는 camera::ray_color()*
이 새로운 깊이 제한을 사용하도록 main() 함수를 업데이트합니다:
int main()
{
...
Camera camera;
camera.aspectRatio = 16.0 / 9.0;
camera.imageWidth = 400;
camera.samplesPerPixel = 100;
camera.maxDepth = 50;
camera.Render(world);
}
C++
복사
이 매우 간단한 장면의 경우 기본적으로 동일한 결과를 얻어야 합니다:
*이미지 8: 제한된 반사 횟수를 가진 확산 구의 두 번째 렌더링*
Fixing Shadow Acne
해결해야 할 미묘한 버그가 있습니다. 광선이 표면과 교차할 때 교차점을 정확하게 계산하려고 시도하지만, 이 계산은 부동 소수점 반올림 오류에 취약합니다. 그 결과 교차점이 아주 약간 벗어날 수 있습니다. 이는 다음 광선의 원점(표면에서 무작위로 산란되는 광선)이 표면과 완벽하게 일치하지 않을 가능성이 높다는 것을 의미합니다. 표면보다 약간 위에 있을 수도 있고, 약간 아래에 있을 수도 있습니다.
광선의 원점이 표면보다 약간 아래에 있으면 그 표면과 다시 교차할 수 있습니다. 즉, hit 함수가 제공하는 부동 소수점 근사값에 관계없이 t=0.00000001에서 가장 가까운 표면을 찾게 됩니다. 가장 간단한 해결 방법은 계산된 교차점에 매우 가까운 충돌을 무시하는 것입니다:
class Camera
{
...
private:
...
Color RayColor(const Ray& ray, int depth, const Hittable& world) const
{
// If we've exceeded the ray bounce limit, no more light is gathered
if (depth <= 0)
{
return Color(0.0, 0.0, 0.0);
}
HitRecord hitRecord;
if (world.Hit(ray, Interval(0.001, Infinity), hitRecord))
{
Vec3 direction = RandomOnHemisphere(hitRecord.normal);
return 0.5 * RayColor(Ray(hitRecord.point, direction), depth - 1, world);
}
Vec3 unitDirection = UnitVector(ray.Direction());
auto a = 0.5 * (unitDirection.Y() + 1.0);
return (1.0 - a) * Color(1.0, 1.0, 1.0)
+ a * Color(0.5, 0.7, 1.0);
}
};
C++
복사
*리스팅 54: [camera.h] 허용 오차를 적용한 반사 광선 원점 계산*
이렇게 하면 그림자 여드름 문제가 해결됩니다. 네, 정말로 그렇게 불립니다. 결과는 다음과 같습니다:
*이미지 9: 그림자 여드름이 없는 확산 구*
True Lambertian Reflection
반구 전체에 걸쳐 반사된 광선을 균일하게 산란시키면 부드러운 확산 모델이 생성되지만, 더 나은 방법이 있습니다. 실제 확산 물체를 더 정확하게 표현하는 것은 람베르티안(Lambertian) 분포입니다. 이 분포는 반사된 광선을 cos(ϕ)에 비례하는 방식으로 산란시킵니다. 여기서 ϕ는 반사된 광선과 표면 법선 사이의 각도입니다. 즉, 반사된 광선은 표면 법선에 가까운 방향으로 산란될 가능성이 가장 높고, 법선에서 멀어질수록 산란 가능성이 낮아집니다. 이 불균일한 람베르티안 분포는 이전의 균일한 산란보다 실제 세계의 재질 반사를 더 잘 모델링합니다.
법선 벡터에 무작위 단위 벡터를 추가하여 이 분포를 만들 수 있습니다. 표면의 교차점에는 충돌 지점 p와 표면 법선 n이 있습니다. 교차점에서 표면은 정확히 두 개의 면을 가지므로, 접하는 고유한 단위 구는 두 개만 존재합니다(표면의 각 면마다 하나씩). 이 두 단위 구는 반지름 길이만큼 표면에서 이동하는데, 단위 구의 경우 정확히 1입니다.
하나의 구는 표면 법선(n) 방향으로 이동하고, 다른 구는 반대 방향(−n)으로 이동합니다. 이렇게 하면 교차점에서 표면에 딱 닿는 두 개의 단위 크기 구가 남습니다. 하나는 중심이 (P+n)에 있고, 다른 하나는 중심이 (P−n)에 있습니다. 중심이 (P−n)에 있는 구는 표면 내부에 있는 것으로 간주되고, 중심이 (P+n)에 있는 구는 표면 외부에 있는 것으로 간주됩니다.
광선 원점과 같은 쪽에 있는 접선 단위 구를 선택합니다. 이 단위 반지름 구에서 무작위 점 S를 선택하고, 충돌 지점 P에서 무작위 점 S로 광선을 보냅니다(이것이 벡터 (S−P)입니다):
*그림 14: 람베르티안 분포에 따라 무작위로 벡터 생성하기*
변경 사항은 실제로 상당히 미미합니다:
class Camera
{
...
private:
...
Color RayColor(const Ray& ray, int depth, const Hittable& world) const
{
// If we've exceeded the ray bounce limit, no more light is gathered
if (depth <= 0)
{
return Color(0.0, 0.0, 0.0);
}
HitRecord hitRecord;
if (world.Hit(ray, Interval(0.001, Infinity), hitRecord))
{
Vec3 direction = hitRecord.Normal + RandomUnitVector();
return 0.5 * RayColor(Ray(hitRecord.point, direction), depth - 1, world);
}
Vec3 unitDirection = UnitVector(ray.Direction());
auto a = 0.5 * (unitDirection.Y() + 1.0);
return (1.0 - a) * Color(1.0, 1.0, 1.0)
+ a * Color(0.5, 0.7, 1.0);
}
};
C++
복사
*리스팅 55: [camera.h] 대체 확산을 사용한 ray_color()*
렌더링 후 비슷한 이미지를 얻습니다:
*이미지 10: 람베르티안 구의 올바른 렌더링*
두 구로 이루어진 장면이 매우 단순하기 때문에 두 확산 방법의 차이를 구분하기는 어렵습니다. 하지만 두 가지 중요한 시각적 차이를 알아차릴 수 있습니다:
1.
변경 후 그림자가 더 뚜렷해집니다
2.
변경 후 두 구 모두 하늘에서 나온 파란색을 띱니다
이 두 변화는 광선의 덜 균일한 산란 때문입니다. 더 많은 광선이 법선 방향으로 산란됩니다. 확산 물체의 경우, 카메라 쪽으로 반사되는 빛이 적어 더 어둡게 보입니다. 그림자의 경우, 더 많은 빛이 바로 위쪽으로 반사되므로 구 아래 영역이 더 어둡습니다.
완벽하게 확산되는 일상 물체는 많지 않습니다. 따라서 빛 아래에서 이러한 물체가 어떻게 작동하는지에 대한 시각적 직관이 제대로 형성되어 있지 않을 수 있습니다. 책을 진행하면서 장면이 더 복잡해질 때, 여기에 제시된 서로 다른 확산 렌더러 간에 전환해 보기를 권장합니다. 대부분의 장면에는 많은 양의 확산 재질이 포함되어 있습니다. 장면 조명에 대한 서로 다른 확산 방법의 효과를 이해하면 귀중한 통찰력을 얻을 수 있습니다.
Using Gamma Correction for Accurate Color Intensity
구 아래의 그림자를 주목하세요. 이미지가 매우 어둡지만, 우리 구는 각 반사마다 에너지의 절반만 흡수하므로 50% 반사체입니다. 구는 꽤 밝게 보여야 하지만(실제로는 밝은 회색), 다소 어둡게 보입니다. 확산 재질의 전체 밝기 범위를 살펴보면 이를 더 명확하게 알 수 있습니다. ray_color 함수의 반사율을 0.5(50%)에서 0.1(10%)로 설정하는 것부터 시작합니다:
class Camera
{
...
private:
...
Color RayColor(const Ray& ray, int depth, const Hittable& world) const
{
// If we've exceeded the ray bounce limit, no more light is gathered
if (depth <= 0)
{
return Color(0.0, 0.0, 0.0);
}
HitRecord hitRecord;
if (world.Hit(ray, Interval(0.001, Infinity), hitRecord))
{
Vec3 direction = hitRecord.Normal + RandomUnitVector();
return 0.1 * RayColor(
Ray(hitRecord.Point, direction),
depth - 1,
world
);
}
Vec3 unitDirection = UnitVector(ray.Direction());
auto a = 0.5 * (unitDirection.Y() + 1.0);
return (1.0 - a) * Color(1.0, 1.0, 1.0)
+ a * Color(0.5, 0.7, 1.0);
}
};
C++
복사
*리스팅 56: [camera.h] 10% 반사율을 가진 ray_color()*
이 새로운 10% 반사율로 렌더링합니다. 그런 다음 반사율을 30%로 설정하고 다시 렌더링합니다. 50%, 70%, 마지막으로 90%에 대해 반복합니다. 선택한 사진 편집기에서 이 이미지들을 왼쪽에서 오른쪽으로 겹쳐 놓으면 선택한 색상 범위의 밝기 증가를 매우 멋지게 시각적으로 표현할 수 있습니다. 지금까지 우리가 작업해 온 것은 다음과 같습니다:
*이미지 11: 지금까지 우리 렌더러의 색상 범위*
자세히 보거나 색상 선택 도구를 사용하면 50% 반사율 렌더링(가운데 있는 것)이 흰색과 검은색(중간 회색) 사이의 중간 지점이 되기에는 너무 어둡다는 것을 알 수 있습니다. 실제로 70% 반사체가 중간 회색에 더 가깝습니다. 이유는 거의 모든 컴퓨터 프로그램이 이미지 파일에 기록되기 전에 이미지가 "감마 보정"되었다고 가정하기 때문입니다. 이는 0에서 1 사이의 값이 바이트로 저장되기 전에 어떤 변환이 적용된다는 것을 의미합니다. 변환 없이 작성된 데이터를 가진 이미지는 선형 공간에 있다고 하고, 변환된 이미지는 감마 공간에 있다고 합니다. 사용 중인 이미지 뷰어는 감마 공간의 이미지를 예상하고 있지만, 우리는 선형 공간의 이미지를 제공하고 있습니다. 이것이 우리 이미지가 부정확하게 어둡게 보이는 이유입니다.
이미지가 감마 공간에 저장되어야 하는 많은 좋은 이유가 있지만, 우리 목적상 이를 인식하기만 하면 됩니다. 이미지 뷰어가 이미지를 더 정확하게 표시할 수 있도록 데이터를 감마 공간으로 변환할 것입니다. 간단한 근사치로, 감마 공간에서 선형 공간으로 갈 때 사용하는 거듭제곱인 "감마 2"를 변환으로 사용할 수 있습니다. 선형 공간에서 감마 공간으로 가야 하므로 "감마 2"의 역을 취해야 하는데, 이는 1/감마의 지수를 의미하며 이는 제곱근입니다. 또한 음수 입력을 견고하게 처리해야 합니다.
inline double LinearToGamma(double linearComponent)
{
if (linearComponent > 0.0)
{
return std::sqrt(linearComponent);
}
return 0.0;
}
void WriteColor(std::ostream& out, const Color& pixelColor)
{
auto red = pixelColor.X();
auto green = pixelColor.Y();
auto blue = pixelColor.Z();
// Apply a linear to gamma transform for gamma = 2.0
red = LinearToGamma(red);
green = LinearToGamma(green);
blue = LinearToGamma(blue);
// Translate the [0, 1] component values to the byte range [0, 255]
static const Interval intensity(0.000, 0.999);
int redByte = static_cast<int>(256.0 * intensity.Clamp(red));
int greenByte = static_cast<int>(256.0 * intensity.Clamp(green));
int blueByte = static_cast<int>(256.0 * intensity.Clamp(blue));
// Write out the pixel color components
out << redByte << ' ' << greenByte << ' ' << blueByte << '\n';
}
C++
복사
*리스팅 57: [color.h] 감마 보정이 적용된 write_color()*
이 감마 보정을 사용하면 이제 어두움에서 밝음으로 훨씬 더 일관된 변화를 얻을 수 있습니다:
*이미지 12: 감마 보정된 렌더러의 색상 범위*

















