上一章中完成了对图形渲染的饿BVH加速,现在我们要尝试将图片纹理映射到物体中。

纹理映射

图形学中的纹理映射是将材质效果应用于场景中的物理过程。其中"纹理"指的是效果(这个效果可以是材质属性,或是部分存在与否)本身,而映射则是将效果映射到物体的表面上。

最为常见的纹理映射就是将图像映射到物体表面上,就像是把世界地图依附到球体表面。和我们的直觉不同,纹理映射是一个逆向的过程,我们首先确定物体上的一个点,然后查找纹理贴图给定的颜色,以实现对图片的映射。

不过在此之前,我们先用程序化的方式生成纹理颜色,并创建一个纹理贴图。为了执行纹理查找,我们需要物体表面的纹理坐标(u,v)以定位纹理中的像素,同时也需要保存当前点的三维坐标(部分纹理需要这一部分的信息)

恒定色彩纹理

我们的纹理颜色类将从最简单的恒定色彩纹理开始实现,实际上我们可以将物体的颜色也视作一种纹理:

#ifndef TEXTURE_H
#define TEXTURE_H

#include "utils.h"

class texture{
    public:
        virtual ~texture() = default;
        virtual color value(double u, double v, point3& p) const = 0;
};
class solid_color : public texture{
    public:
        solid_color(const color& albedo) : albedo(albedo) {}

        solid_color(double red, double green, double blue) : solid_color(color(red,green,blue)) {}

        color value(double u, double v, point3& p){
            return albedo;
        }
    private:
        color albedo;
};

#endif

这里我们先实现对纹理类的接口的一个实现,然后创建一个恒定色彩纹理类,返回恒定的颜色类型。

注意到我们这里需要使用到(u,v)表面坐标,我们还需要更新hit_record结构,对这些射线碰撞信息进行存储:

class hit_record {
public:
    point3 p;
    double u;
    double v;
    vec3 normal;
	...
};

棋盘纹理

棋盘纹理作为实体纹理中的一种。实体纹理取决点在空间中的位置,我们可以理解为给空间中的指定点进行着色,而不是给空间中的某个物体上色。正因如此,当我们的物体在空间中移动时,纹理并不会随物体进行移动,反而像是物体在穿过纹理。

这里我们将实现一个棋盘纹理类,它是一种空间纹理(即实体纹理)。根据点在空间中给定的位置进行纹理颜色的渲染。

为了实现棋盘格图案,我们需要对当前点的每个坐标分量取floor,这样我们就将整个空间分割为1x1x1的单元格,每个坐标都可以映射到对应的单元格,我们对奇数的单元格赋予一种颜色,对偶数赋予另外的一种颜色,这样就实现了棋盘的样式。同时我们还可以设置一个缩放因子scale,控制单元格的大小,从而实现对棋盘格的大小控制:

class checker_texture : public texture{
    public:
        checker_texture(double scale, shared_ptr<texture> even, shared_ptr<texture> odd)
            : inv_scale(1.0/scale), even(even), odd(odd) {}

        checker_texture(double scale, const color& c1, const color& c2)
            : checker_texture(scale, make_shared<solid_color>(c1), make_shared<solid_color>(c2)) {}

        color value(double u, double v, point3& p) const override{
            auto xInt = int(floor(inv_scale*p.x()));
            auto yInt = int(floor(inv_scale*p.y()));
            auto zInt = int(floor(inv_scale*p.z()));

            bool isEven = (xInt + yInt + zInt) % 2 == 0;

            return isEven ? even->value(u,v,p) : odd->value(u,v,p);
        }

    private:
        double inv_scale;
        shared_ptr<texture> even;
        shared_ptr<texture> odd;
};

为了进一步的支持纹理,我们拓展lambertian类,使用纹理代替颜色:

class lambertian : public material {
public:
    lambertian(const color& albedo) : tex(make_shared<solid_color>(albedo)) {}

    lambertian(shared_ptr<texture> tex) : tex(tex) {}

    virtual bool scatter(
        const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
    ) override {
        auto scatter_direction = rec.normal + random_unit_vector();
        if (scatter_direction.near_zero()) {
            scatter_direction = rec.normal;
        }
        scattered = ray(rec.p, scatter_direction, r_in.time());
        attenuation = tex->value(rec.u, rec.v, rec.p);
        return true;
    }

private:
    shared_ptr<texture> tex;
};

这里通过多态的思想实现了对lambertian材质的纹理的实现。

现在我们可以向我们的场景中添加纹理了:

    // main.cpp中将地面设置成棋盘样式
	auto checker = make_shared<checker_texture>(0.2, color(0,0,0), color(1,1,1));
    auto ground_material = make_shared<lambertian>(checker);
    world.add(make_shared<sphere>(point3(0,-1000,0), 1000, ground_material));

渲染结果如下:

image.png

空间纹理的特殊情况

正如之前所提到的,这种实体纹理的上色方式,更像是物体在穿过纹理,从而完成上色。

我们现在创建一个新的场景来观察这种特殊的情况,我们将先前的main函数保存为一个bounding_ball场景,然后现在我们再来创建一个新的场景:

void checkered_spheres() {
    hittable_list world;

    auto checker = make_shared<checker_texture>(0.32, color(.2, .3, .1), color(.9, .9, .9));

    world.add(make_shared<sphere>(point3(0,-10, 0), 10, make_shared<lambertian>(checker)));
    world.add(make_shared<sphere>(point3(0, 10, 0), 10, make_shared<lambertian>(checker)));

    camera cam;

    cam.aspect_ratio      = 25.0 / 16.0;
    cam.image_width       = 800;
    cam.samples_per_pixel = 100;
    cam.max_depth         = 50;

    cam.vfov     = 20;
    cam.lookfrom = point3(13,2,3);
    cam.lookat   = point3(0,0,0);
    cam.vup      = vec3(0,1,0);

    cam.defocus_angle = 0;

    cam.render(world);
}

我们通过main函数中的switch来切换我们想要渲染的场景:

int main(){
    switch(2){
        case 1: bounding_ball(); break;
        case 2: checkered_spheres(); break;
    }
}

我们渲染出来的结果如下:

image.png

这就是空间纹理渲染的特殊情况,不过你应该能理解这是什么情况。所以为了解决这个问题,我们需要使用表面纹理,这就意味着我们需要根据(u,v)的球体表面位置信息来创建纹理。

球体表面纹理坐标

空间纹理通过空间中一点的坐标,实现纹理的绘制。但是真如我们先前所提到的空间纹理的局限性,我们希望能够更具球体表面的坐标实现对图像点对点的映射。这就以为着我们需要一种方法来查找三维球体表面任意点的坐标(u,v)

这里用到一个经纬度的思想,首先确定出这个点在球体上的经纬度(θ,ϕ)(\theta,\phi)(横纬竖经,这里θ\theta从-Y向上,ϕ\phi从-X到+Z到+X到-Z),然后再将球面坐标表示出来,这里的话,如果学过球面坐标,自然能够得到以下式子: $$ \begin{aligned}

y &= -cos(\theta) \ x &= -cos(\phi)sin(\theta) \ z &= sin(\phi)sin(\theta)

\end{aligned}

我们可以简单的推导得到: 我们可以简单的推导得到:
\begin{aligned} \frac{x}{z} &= - \frac{cos(\phi)}{sin(\phi)} \ \text{则有 } \phi &= atan2(z,-x) + \pi \ \theta &= arccos(-y) \end{aligned} $$ 所以我们可以写出sphere类中的get_uv方法:

    static void get_uv(const point3& p, double& u, double& v){
        auto theta = std::acos(-p.y());
        auto phi = std::atan2(-p.z(),p.x()) + pi;
        u = phi / (2*pi);
        v = theta / pi;
    }

然后我们向hit方法中添加,每次碰撞记录的(u,v):

    bool hit(const ray& r, interval t_range, hit_record& rec) const override {
		...
        rec.t = root;
        rec.p = r.at(rec.t);
        vec3 outward_normal = (rec.p - cur_center) / radius;
        rec.set_face_normal(r, outward_normal);
        // 这里的outward 就是从球心指向碰撞点的向量
        get_uv(outward_normal,rec.u,rec.v);
        rec.mat = mat;
        return true;
    }

现在我们就实现了对球体表面的位置的(u,v)二维定位

访问纹理图像数据

现在我们需要一种手段,将图片数据解析为一种二维关系,我们希望通过(u,v)访问图像数据上对应的像素值。所以我们需要将图片加载为一个浮点数数组,便于我们访问。这里我们使用第三方库stb_image.h来实现

首先我们创建一个辅助类来帮助我们管理图片信息内容,以提供一个pixel_data(int x,int y)方法,来访问任意像素的8位(unsigned char)RGB。

我们的实现如下:

#ifndef IMAGE_H
#define IMAGE_H

#ifdef _MSC_VER
    #pragma warning (push,0)
#endif

#define STB_IMAGE_IMPLEMENTATION
#define STBI_FAILURE_USERMSG

#include "stb_image.h"
#include "utils.h"

class image{
    public:
        image(){}

        image(const char* image_file){
            auto filename = std::string(image_file);
            if(load("../images/" + filename)) return;

            std::cerr << "ERROR: Could not load image file" << image_file << "\n";
        }

        ~image(){
            delete[] bdata;
            STBI_FREE(fdata);
        }

        bool load(const std::string& filename){
            auto n = bytes_per_pixel;
            fdata = stbi_loadf(filename.c_str(), &image_width, &image_height, &n, bytes_per_pixel);
            if(fdata == nullptr) return false;

            byte_per_scanline = bytes_per_pixel * image_width;
            convert_to_bytes();
            return true;
        }

        int width() {return (fdata==nullptr) ? 0 : image_width;}
        int height() {return (fdata==nullptr) ? 0 : image_height;}

        const unsigned char* pixel_data(int x, int y) const{
            static unsigned char magenta[] = {255,0,255};
            if(bdata==nullptr) return magenta;

            x = clamp(x,0,image_width);
            y = clamp(y,0,image_height);

            return bdata + x*bytes_per_pixel + y*byte_per_scanline;
        }

    private:
        const int bytes_per_pixel = 3;      // 每像素的位数(即通道数)
        float* fdata = nullptr;             // 浮点像素数据
        unsigned char* bdata = nullptr;     // 8bit像素数据
        int image_width = 0;                // 图像宽度
        int image_height = 0;               // 图像高度
        int byte_per_scanline = 0;          // 宽的像素数量

    	// return [low,high)
        static int clamp(int x, int low, int high){
            if(x < low) return low;
            if(x < high) return x;
            return high - 1;
        }

        static unsigned char float_to_byte(float value){
            if(value <= 0.0)
                return 0;
            if(value >= 1.0)
                return 255;
            return static_cast<unsigned>(value*256.0);
        }

        void convert_to_bytes(){
            int total_bytes = bytes_per_pixel * image_height * image_width;
            bdata = new unsigned char[total_bytes];

            auto *bptr = bdata;
            auto *fptr = fdata;
            for(auto i=0; i<total_bytes ; i++,fptr++,bptr++)
                *bptr = float_to_byte(*fptr);
        }
};


#ifdef _MSC_VER
    #pragma warning (pop)
#endif

#endif

现在我们封装好了一个加载并获取图像内容的image类,我们可以利用它实现图像纹理image_texture:


class image_texture : public texture{
    public:
        image_texture(const char* filename) : image(filename){}

        color value(double u, double v, const point3& p) const override{
            if(image.height() <= 0) return color(0,1,1);

            u = interval(0,1).clamp(u);
            // 由于图片坐标起始位置是从左上角开始
            // 而图形学坐标从左下角开始,所以需要进行反转
            v = 1.0 - interval(0,1).clamp(v);
            
            auto i = int(u*image.width());
            auto j = int(v*image.height());
            auto pixel = image.pixel_data(i,j);

                    auto color_scale = 1.0 / 255.0;
        return color(color_scale*pixel[0], color_scale*pixel[1], color_scale*pixel[2]);
        }

    private:
        image image;
};

图像纹理渲染

现在我们可以尝试将一个图片进行渲染:

void earth() {
    auto earth_texture = make_shared<image_texture>("earthmap.jpg");
    auto earth_surface = make_shared<lambertian>(earth_texture);
    auto globe = make_shared<sphere>(point3(0,0,0), 2, earth_surface);

    camera cam;

    cam.aspect_ratio      = 25.0 / 16.0;
    cam.image_width       = 2000;
    cam.samples_per_pixel = 100;
    cam.max_depth         = 10;

    cam.vfov     = 20;
    cam.lookfrom = point3(-3.75,4,-9);
    cam.lookat   = point3(0,0,0);
    cam.vup      = vec3(0,1,0);

    cam.defocus_angle = 0;

    cam.render(hittable_list(globe));
}

我们使用一张地球的贴图(效果如下):

image.png

至此 我们的纹理样式就简单介绍完了