Odbicia światła na scenie

Do tej pory nie wykorzystaliśmy jeszcze największej siły implementowanej metody generowania obrazu polegającej na możliwości śledzenia nie tylko promieni bezpośrednio wpadających do oka, ale także tego, jak odbijają się one po scenie.

Z odbiciami mamy do czynienia prawie wszędzie:

  • Kiedy źródło światła oświetla jakiś obiekt, to nie świeci on własnym światłem, ale odbitym z tego źródła.

  • W lustrze widzimy odbicie sceny.

  • Przeźroczyste obiekty, soczewki itp. załamują światło w nie wpadające.

Teraz, będziemy mogli dodawać materiały (Material), które wewnątrz funkcji get_color() będą wywoływać funkcję compute_hit_object ze zwiększonym parametrem bounce_index. Moglibyśmy obrać jedno z dwóch podejść:

  • Pojedynczy promień trafiający w obiekt rozdziela się na wiele promieni, żeby dokładnie ustalić oświetlenie tego obiektu. Na podstawie szczegółowej informacji o oświetleniu wyliczamy dokładny kolor promienia odbitego w stronę oka.

  • Po trafieniu w obiekt wybierzemy losowo tylko jeden z możliwych kierunków odbicia, ale z jednego piksela będziemy wysyłali wiele promieni, żeby obierały różne ścieżki na naszej scenie.

Użyjemy tego drugiego podejścia, ponieważ pozwoli nam łatwiej podglądać częściowo wygenerowane obrazki: po wystrzeleniu pierwszej próbki z każdego promienia wygenerujemy podgląd, następnie po drugiej próbce itp. W tym rozwiązaniu właściwie potrzebowalibyśmy jeszcze jednej, większej zmiany w funkcji render polegającej na tym, że zamiast policzyć tylko raz kolor każdego piksela, będziemy liczyli go wiele razy z uśrednianiem wyniku. Ta druga zmiana jest schowana w w3_ray.py (klasa VarianceAwareSampler przyjmuje kolejne próbki w funkcji add() i podaje uśredniony wynik przez funkcję get()).

Podstawowe materiały odbijajace promienie

Zaczniemy od zaimplementowania matowego materiału, czyli takiego, który odbija światło równomiernie we wszystkich kierunkach. Funkcja get_color() takiego materiału po prostu odbije promień w losowym kierunku na półsferze wokół wektora normalnego (nie chcemy odbijać promienia do środka materiału):

class DiffuseMaterial(Material):
    def __init__(self, r: float, g: float, b: float):
        self.r = r
        self.g = g
        self.b = b

    def get_color(self, hit: HitData) -> Color:
        # Wygeneruj losowy wektor; ponawiamy losowanie tak długo, jak dostajemy wektor nieleżący na
        # tej samej półsferze, co normalna.
        bounce = Vec3.random()
        while hit.surf_normal.dot(bounce) < 0:
            bounce = Vec3.random()
        
        # Odbijamy promień i pobieramy kolor z trafionego obiektu
        # Zwróć uwagę, że punkt początkowy to nie jest dokładnie punkt tego trafienia
        hit2 = compute_hit_object(Ray(hit.position + 0.000_001 * bounce, hit.position + bounce),
                                  hit.bounces + 1)
        if hit2:
            color = hit2.object.get_color(hit2)
            return Color(int(self.r * color.r), int(self.g * color.g), int(self.b * color.b))
        return COLOR_BLACK

Przykład

Ghosts!

Antialiasing

Do tej pory, kiedy rysowaliśmy sfery z EmissiveMaterial, miały one bardzo niegładkie krawędzie. To dlatego, że ciągle wystrzeliwywaliśmy testowe promienie ze środków pikseli. Tak naprawdę kolor piksela nie jest taki, jak kolor jego środka. Możemy to poprawić dodając więcej próbek z różnych punktów w tym pikselu.

Antialiasing

Materiały oparte o rzutowanie wektorów

Iloczyn skalarny pozwala policzyć nam rzut prostopadły jednego wektora na drugi:

Iloczyn skalarny

W związku z tym wektor odbity (symetryczny względem wektora normalnego) wynosi d - 2 * d.dot(n) * n, gdzie d to wektor kierunku promienia, a n to normalna do obiektu. (Polecam rozrysować sobie to na karce).

Mirror

Ze wzoru na wektor odbity korzystają też przeźroczyste materiały załamujące światło, ale nie umiem tego wyjaśnić, mogę tylko podać kod:

class HopefullyGlassMaterial(Material):
    def __init__(self, eta: float):
        self.eta = eta

    def get_color(self, hit: HitData) -> Color:
        n = hit.surf_normal
        d = hit.ray_direction
        eta = self.eta
        cosi = -n.dot(d)
        k = 1.0 - eta * eta * (1.0 - cosi * cosi)
        ref = d - 2 * d.dot(n) * n if k < 0 else eta * d + (eta * eta - math.sqrt(k)) * n
        hit2 = compute_hit_object(Ray(hit.position + 0.000_001 * ref, hit.position + ref),
                                  hit.bounces + 1)
        if hit2:
            return hit2.object.get_color(hit2)
        return COLOR_BLACK

HopefullyGlass

Na zakończenie

  1. Raytracing wymaga mega dużo obliczeń O_O

  2. Nawet on nie potrafi wykonać wszystkich rzeczy, które dzieją się ze światłem (tych opartych o falową naturę światła np.)

  3. Można robić jeszcze dużo innych rzeczy: volumetric materials, przyśpieszanie renderowania (podziały przestrzeni, cache-owanie informacji o oświetleniu), obliczenia na GPU…

  4. Zapraszamy na dalsze zajęcia <3

Dodatkowe czytanki:

  1. Ray Tracing in One Weekend napisane z ideą podobną do naszej, ale lepiej dopracowany jako czytanka, no i jest tam więcej materiału; używa C++.

  2. Physically Based Rendering: From Theory To Implementation bardzo długa i ambitna książka, jak ktoś chce połamać na tym zęby. Raytracer, którego implementację tam opisują (czyli pbrt) bywa używany do testowania nowych technik w grafice komputerowej.

  3. Kiedyś opublikujemy kod z tych zajęć na GitHubie i wyślemy linka na naszym kanale na Discordzie. Jak ktoś chce, to może też zapytać Krystiana, czy nie podzieliłby się swoim raytracerem w C++ ^^