Ana içeriğe geç

Bölüm 8: Mat Materyaller (Diffuse / Lambertian)

Şimdiye kadar piksellerimizi yalnızca yüzey normallerine göre renklendirdik. Ancak gerçek dünyada mat nesneler (örneğin tebeşir, kil, ham ahşap veya mat boyalı bir duvar) kendi renklerini yaymazlar; çevrelerindeki ışıktan beslenirler ve çarpan ışığı her yöne rastgele saçar.

İşte bilgisayar grafiklerinde fotogerçekçiliğin sırrı burada yatar: Dolaylı Aydınlatma (Indirect Lighting / Global Illumination)!


Basit Bir Mat Materyal (A Simple Diffuse Material)

Mat yüzeyler ışığı doğrudan bir ayna gibi tek bir açıyla yansıtmaz. Çarpan bir ışık ışını, yüzeyin mikro pürüzlülüğü nedeniyle rastgele bir açıyla geri seker:

Işık Sıçraması

Peki bir ışın yüzeye çarptığında sekeceği rastgele yönü nasıl belirleyeceğiz?

En sezgisel yöntem şudur: Çarpışma noktasında yüzeye teğet duran bir birim küre hayal edin. Bu kürenin merkezi \(\mathbf{P} + \mathbf{n}\) noktasıdır (\(\mathbf{P}\) çarpışma noktası, \(\mathbf{n}\) yüzey normali).

Yüzeye Teğet Birim Küre

Eğer biz bu birim kürenin içinden rastgele bir nokta \(\mathbf{S}\) seçer ve çarpışma noktasından o noktaya doğru bir ışın gönderirsek \((\mathbf{S} - \mathbf{P})\), fiziksel olarak çok inandırıcı bir saçılma elde ederiz!


Rastgele Vektör Üretimi: Reddetme Yöntemi (Rejection Method)

Bir kürenin içinden rastgele bir nokta seçmenin en basit yolu Reddetme Yöntemidir (Rejection Method): 1. \(X, Y, Z\) bileşenleri \([-1.0, +1.0]\) arasında olan rastgele bir küp içinde nokta seçilir. 2. Bu noktanın orijine olan uzaklığının karesi \(1\)'den büyükse nokta reddedilir ve yeni bir nokta seçilir. 3. Nokta birim kürenin içindeyse kabul edilir!

Birim Küre İçi Rastgele Nokta

vec3.h (Rastgele Vektör Yardımcıları)
class vec3 {
    // ...
  public:
    static vec3 random() {
        return vec3(random_double(), random_double(), random_double());
    }

    static vec3 random(double min, double max) {
        return vec3(random_double(min,max), random_double(min,max), random_double(min,max));
    }
};

inline vec3 random_in_unit_sphere() {
    while (true) {
        auto p = vec3::random(-1, 1);
        if (p.length_squared() < 1)
            return p;
    }
}

Bu rastgele noktayı normalize ederek birim vektör haline getirelim:

vec3.h (Birim Vektör)
1
2
3
inline vec3 random_unit_vector() {
    return unit_vector(random_in_unit_sphere());
}

Özyinelemeli Sıçrama Derinliğini Sınırlama (Limiting Child Rays)

Bir ışın nesneye çarptığında yeni bir saçılan ışın fırlatır. O ışın gider zemine çarpar, oradan başka bir küreye seker...

Eğer ışın iki küre arasında sonsuza kadar gidip gelirse ne olur? Stack Overflow (Yığın Taşması) gerçekleşir ve programınız çöker!

Bunu engellemek için ışınlarımıza bir Maksimum Sıçrama Derinliği (max_depth) limiti koyarız (örneğin 50 veya 10 sıçrama):

camera.h (Özyinelemeli ray_color)
color ray_color(const ray& r, int depth, const hittable& world) const {
    // Sıçrama sınırını aştıysak daha fazla ışık toplanmaz
    if (depth <= 0)
        return color(0, 0, 0);

    hit_record rec;

    if (world.hit(r, interval(0.001, infinity), rec)) {
        vec3 direction = rec.normal + random_unit_vector();
        return 0.5 * ray_color(ray(rec.p, direction), depth - 1, world);
    }

    vec3 unit_direction = unit_vector(r.direction());
    auto a = 0.5*(unit_direction.y() + 1.0);
    return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

Buradaki 0.5 katsayısı, mat yüzeyin her sıçramada ışığın enerjisinin %50'sini emdiğini (absorbe ettiğini) simgeler.


Gölge Aknesi (Shadow Acne) Hatası ve Çözümü

Yukarıdaki kodda çok kritik ve sinsi bir detay bulunur:

world.hit(r, interval(0.001, infinity), rec) // Neden 0 değil de 0.001?

Bilgisayarlar reel sayıları sınırlı bir kayan noktalı sayı (floating point) hassasiyetiyle saklar. Bir ışın \(t = 1.000000\) noktasında küreye çarptığında, bir sonraki saçılan ışın matematiksel olarak tam o yüzeyden başlamalıdır.

Fakat yuvarlama hataları yüzünden fırlatılan yeni ışının başlangıç noktası bazen küre yüzeyinin hemen altında (\(t = -0.000001\)) kalır! Bu durumda yeni ışın henüz doğar doğmaz kendi çıktığı küreye çarpar!

Sonuç: Yüzeyin üzerinde çirkin siyah benekler, dalgalanmalar ve kirli gölgeler (Shadow Acne / Gölge Aknesi)!

Aralığın alt sınırını 0.0 yerine 0.001 yaparak ışının yüzeyden çok azıcık ileriden başlamasını sağlar ve bu sinsi hatayı tamamen ortadan kaldırırız:

Gölge Aknesi Giderilmiş Temiz Render


Gerçek Lambertian Yansıma (True Lambertian Reflection)

Yukarıda kullandığımız rastgele nokta seçimi pratik bir yaklaşımdı. Ancak fiziksel olarak kusursuz bir mat yüzey (Johann Heinrich Lambert'in 1760'ta tanımladığı Lambertian Dağılımı), ışığı yüzey normali yönünde \(\cos(\theta)\) olasılığıyla daha yoğun saçmalıdır.

Bunu sağlamak için yüzeye teğet duran birim kürenin yüzeyinden rastgele bir birim vektör seçeriz:

Lambertian Saçılma Geometrisi Rastgele Birim Vektör

\[\text{hedef\_yön} = \mathbf{n} + \text{random\_unit\_vector}()\]

Bu formül, ışınların yüzey normaline yakın doğrultularda toplanmasını, yüzeye teğet (sıyırıp geçen) açılarda ise seyrekleşmesini kusursuz bir şekilde sağlar.


Gama Düzeltmesi (Gamma Correction)

Mat küremizi bu formülle render ettiğimizde görüntünün gereğinden belirgin şekilde karanlık olduğunu fark edeceksiniz:

Doğrusal Renk Skalası (Karanlık)

Bunun sebebi ışın izleyicimizin hatası değildir! Suçlu, bilgisayar monitörlerimizin çalışma prensibidir.

İnsan gözü karanlık tonlardaki değişimleri aydınlık tonlardakine göre çok daha hassas algılar. Bu yüzden modern monitörler gelen sinyalleri doğrusal (linear) olarak göstermez; \(V^{2.2}\) şeklinde bir üstel eğriye (gamma curve) tabi tutar.

Eğer biz monitöre doğrudan doğrusal fiziksel ışık değerlerini (\([0.0, 1.0]\)) gönderirsek, monitör bu değerlerin karesini alarak her şeyi olması gerekenden çok daha karanlık gösterir!

Görüntünün insan gözüne doğru görünmesi için, pikselleri dosyaya yazmadan önce renk değerlerinin tersini alarak Gama 2.0 düzeltmesi (karekökünü alma) uygularız:

\[\text{renk}_{\text{gama}} = \sqrt{\text{renk}_{\text{doğrusal}}}\]
color.h (Gama Düzeltmeli write_color)
inline double linear_to_gamma(double linear_component) {
    if (linear_component > 0)
        return std::sqrt(linear_component);
    return 0;
}

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

    // Gama 2.0 dönüşümü
    r = linear_to_gamma(r);
    g = linear_to_gamma(g);
    b = linear_to_gamma(b);

    static const interval intensity(0.000, 0.999);
    int rbyte = int(256 * intensity.clamp(r));
    int gbyte = int(256 * intensity.clamp(g));
    int bbyte = int(256 * intensity.clamp(b));

    out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}

Sonuç: Kürenin altındaki temas gölgesi (ambient occlusion), gökyüzünden seken yumuşacık dolaylı ışıklar ve gerçekçi renk tonları!

Doğru Lambertian ve Gama Düzeltmeli Render