Nori 筆記 (3) Assignment 2 - Rendering Algorithm

Assignment 2: Monte Carlo Sampling and Ambient Occlusion

Part 2: Two Simple Rendering Algorithms

作業 2 的後半部很明顯感受到比前一篇 warping function 還要簡單,畢竟這邊只要寫 code 就好,之前是一堆微積分跟機率。

Part 2.1 Point Lights

總之就是把題目敘述給的公式寫成程式碼。

值得注意的幾個部份,第一個是 $\cos\theta$ 的計算,可以使用 Frame::cosTheta()Frame::toLocal() 來方便求得。第二個是題目敘述有提到的用 shadow ray query 來計算 visibility。

記錄一下踩到的雷。Ray3f 這個 class,我原先以為他只有 ray origin 跟 ray direction,結果後來才發現他有 mintmaxt 兩個參數來決定 ray 的延伸區段。可以在 include/nori/ray.h 裡面看到 struct TRay 的 constructor。要用 shadow ray query 的話,mint 不能設定成 0,否則若起始點有 mesh,就有可能因為浮點數誤差而讓 ray 跟起始點的 mesh 相交;而 maxt 也不能單純設光源到物體的距離,原理相同。兩者都要有一個 epsilon 的誤差。

    Color3f Li(const Scene *scene, Sampler *sampler, const Ray3f &ray) const {
        // find if the ray intersects with the scene
        Intersection its;
        if (!scene->rayIntersect(ray, its))
            return Color3f(0.0f);

        float cos_theta = Frame::cosTheta(its.shFrame.toLocal(light_position - its.p).normalized());
        float distance2 = (its.p - light_position).squaredNorm();

        // use shadow ray query to obtain visibility
        Vector3f direction = its.p - light_position;
        float eps = 1e-3; // the smaller the eps, the more noise caused by self-intersection in the output image
        Ray3f shadow_ray{Point3f{light_position}, direction.normalized(), eps, direction.norm() - eps};
        float visibility = (scene->rayIntersect(shadow_ray)) ? 0.f : 1.f;

        return energy / (4 * pi * pi) * std::max(0.f, cos_theta) / distance2 * visibility;
    }
ajax-simple.xml 畫出來的圖,與題目提供的 reference image 看起來相同。

實驗一下不同的 eps 值,會發現如果設定太小(例如 1e-5)的話,會有一些黑色斑點,推測是浮點數誤差造成 rayIntersect 誤判。可以用下圖來比較。

eps = 1e-3

eps = 1e-5

ajax-simple.xml 的結果比較,不同 eps 值的影響。

Part 2.2 Ambient Occlusion

一樣,把公式寫成 code 即可。

值得注意的是,Ray3f 如果不提供 mintmaxt 的話,預設的值會是 epsilon 跟 infinity(見 include/nori/ray.h)。題目敘述中提到,我們可以把 $\alpha$ 設成 $\infty$,把 ambient occlusion 當成 global effect,因此使用 Ray3f constructor 預設的 mintmaxt 即可。

    Color3f Li(const Scene *scene, Sampler *sampler, const Ray3f &ray) const {
        // find if the ray intersects with the scene
        Intersection its;
        if (!scene->rayIntersect(ray, its))
            return Color3f(0.0f);

        // use cosine weighted hemisphere sampling
        Vector3f direction = Warp::squareToCosineHemisphere(sampler->next2D());

        // use shadow ray query to obtain visibility
        Ray3f shadow_ray{its.p, its.shFrame.toWorld(direction)}; // (mint, maxt) = (eps, inf)
        return scene->rayIntersect(shadow_ray) ? 0.f : 1.f;
    }

在我的電腦上,./nori ajax-ao.xml 要跑 17 秒。

ajax-ao.xml 畫出來的圖,與題目提供的 reference image 看起來相同。
錯誤嘗試(已折疊)

其實我原本寫的版本是自己在 Li() 裡面模擬積分,類似下方程式碼這樣,但這樣執行到天荒地老都跑不完。

    Color3f Li(const Scene *scene, Sampler *sampler, const Ray3f &ray) const {
        ...

        // simulate the integral
        Color3f ret{0.f};
        constexpr int num_sample = 1e3;
        for (int i = 0; i < num_sample; ++i) {
            // use cosine weighted hemisphere sampling
            Vector3f direction = Warp::squareToCosineHemisphere(Point2f{sampler->next2D()});

            // use shadow ray query to obtain visibility
            Ray3f shadow_ray{its.p, its.shFrame.toWorld(direction)}; // (mint, maxt) = (eps, inf)
            if (!scene->rayIntersect(shadow_ray)) {
                ret += 1.f;
            }
        }
        return ret / num_sample;
    }

後來發現,在 src/main.cpp 裡的 renderBlock() 中,可以看到對於每個 pixel,都會呼叫 sampler->getSampleCount()integrator->Li()。也就是說,Monte Carlo averaging 已經發生在這邊了,我們不需要在 integrator 裡自己做積分的模擬。

    /* For each pixel and pixel sample sample */
    for (int y=0; y<size.y(); ++y) {
        for (int x=0; x<size.x(); ++x) {
            for (uint32_t i=0; i<sampler->getSampleCount(); ++i) {
                Point2f pixelSample = Point2f((float) (x + offset.x()), (float) (y + offset.y())) + sampler->next2D();
                Point2f apertureSample = sampler->next2D();

                /* Sample a ray from the camera */
                Ray3f ray;
                Color3f value = camera->sampleRay(ray, pixelSample, apertureSample);

                /* Compute the incident radiance */
                value *= integrator->Li(scene, sampler, ray);

                /* Store in the image block */
                block.put(pixelSample, value);
            }
        }
    }

後記

完成了 Assignment 2 了,很有趣。雖然機率跟微積分的數學有點令人力不從心,但還是感覺學到很多。電腦圖學最吸引我的地方,就是這樣把物理世界的公式,用非常漂亮的數學跟程式來表達和演算出來。看著自己的程式最終產生了想像中的圖,實在非常有成就感。

繼續往 Assignment 3 前進。