美文网首页
pbrt笔记--第三章 Shapes

pbrt笔记--第三章 Shapes

作者: 奔向火星005 | 来源:发表于2019-11-05 11:51 被阅读0次

    3.1 Base Shape Interface(基本图形接口)

    pbrt中所有的图形都继承自一个基类class Shape,它的代码简略如下:

    class Shape {
       public:
        Shape(const Transform *ObjectToWorld, const Transform *WorldToObject,
              bool reverseOrientation);
        virtual ~Shape();
        virtual Bounds3f ObjectBound() const = 0;
        virtual Bounds3f WorldBound() const;
        virtual bool Intersect(const Ray &ray, Float *tHit,
                               SurfaceInteraction *isect,
                               bool testAlphaTexture = true) const = 0;
    
        // Shape Public Data
        const Transform *ObjectToWorld, *WorldToObject;
        const bool reverseOrientation;
        const bool transformSwapsHandedness;
    };
    

    可以看到它的主要两个成员变量ObjectToWorldWorldToObject,因为pbrt中所有的图形都是定义在物体坐标系(object coordinate space)的,例如所有的圆形的中点都在它自身物体坐标系的原点,ObjectToWorldWorldToObject这两个变换用来实现物体坐标系到世界坐标系的转换。

    另外两个成员reverseOrientationtransformSwapsHandedness感觉用的不多,先忽略。

    还有一点需要注意的是所有的shapes存储的transformations都是指针,而不是具体的对象。因为pbrt中有一个Transforms的对象池,详见2.7节。

    3.1.1 Bounding(边界)

    因为在渲染中许多对象的计算是很耗时的,如果有一个3D立方体(也就是第二章的包围盒)可以正好包住它,如果一条光线并没有经过这个包围盒,那就可以避免对这个对象进行昂贵的计算了。

    一个和坐标轴对齐的包围盒只需要6个浮点型的内存(两个对角线的点),并且计算一条射线和包围盒是否相交是几乎不耗时的操作。因此每个Shape的实现都必须实现返回它自身包围盒的接口。也就是上面代码块中的ObjectBound()和WorldBound()。

    ObjectBound()返回的是物体空间的包围盒,WorldBound()返回的是世界坐标系的包围盒。pbrt提供了一个默认的WorldBound()的方法,就是先计算物体坐标系下的包围盒,再变换到世界坐标系下。如下:

    Bounds3f Shape::WorldBound() const {
        return (*ObjectToWorld)(ObjectBound());
    }
    

    但许多Shapes可以根据自身的特点计算出更小更紧凑的包围盒。如下图的一个例子:


    图a是先计算在物体空间中计算出包围盒,然后把包围盒变换到世界坐标系(图a的实线矩形),但是变换到世界坐标系后的矩形已经不是坐标轴对齐了,实际上它的包围盒是虚线的正方形(太大了不紧凑,不好)。而图b中,先把三角形变换到世界坐标系,然后计算它的包围盒,这样的包围盒就比图a的紧凑多了。

    3.1.2 Ray-Bounds Intersections(光线-边界相交)

    Shape的具体实现必须要提供一个(有可能两个)方法来测试光线和shape的交点。第一个是Shape::Intersect(),接口长这个样子,它返回第一个交点的几何信息:

    virtual bool Intersect(const Ray &ray, Float *tHit,
           SurfaceInteraction *isect, bool testAlphaTexture = true) const = 0;
    

    还有几点要注意的:

    1.Ray结构体中国有tMax成员变量,对应的是ray的终点,交点程序必须忽略在终点后面的交点。

    2.如果发现一个交点,该交点和光线起点的距离将存储到tHit指针指向的数据中,如果沿着ray有多个交点,则tHit记录的是最近的一个。

    3.交点的信息存储在SurfaceInteraction结构体中,它完整记录一个表面的几何属性。这个class在整个pbrt中用的非常多,它让光线跟踪器的几何部分彻底从着色和光照部分独立出来

    4.传递到intersection routines的rays是在世界坐标系中,因此shapes需要在intersection tests之前先把他们转换到物体坐标系中。返回的交点信息应该在世界坐标系中。

    另一个intersection test method是Shape::IntersectP(),它只是检查是否相交,而不会返回交点的详细信息...

    3.1.4 Surface Area和3.1.5 Sideness略过。

    3.2 Spheres(球)

    球面是一种特殊的二次曲面,二次曲面是用x,y,z二次多项式描述的表面。球面是最简单的一种曲面,很适合作为光线跟踪器学习的起点。pbrt工程中支持六种曲面:球,圆锥,碟形(一种特殊的圆锥),圆柱,双曲面,抛物面。

    许多表面有两种描述的方式:隐式形式(implicit form)和参数形式(parametric form)。一个描述3D表面的隐式函数为:

    f (x , y , z) = 0

    所有满足这个条件的点就组成这个表面。对于一个中心在原点的单位球体,它的隐式方程就是x2+y2+z2 - 1 = 0.

    许多表面也用参数形式来用2D点映射3D表面的点。例如,对于一个半径r的球体,可以用一个2D球面坐标(θ , φ),其中θ范围从0到π ,φ从0到2π:

    x=r sinθ cosφ
    y=r sinθ sinφ
    z=r cosθ

    如下图(截了第5章的一个图,书中在此处没有好的图展示)


    我们可以将函数f (θ , φ)转换到一个范围为[0, 1]2的函数f (u, v)(这实际上就是纹理坐标!),也可以通过限制θ和φ的范围来产生一个局部球体,只需要θ ∈ [θmin, θmax]和φ ∈ [0, φmax],

    φ = u φ_{max}
    θ = θ_{min} + v(θ_{max} − θ_{min})

    下图展示了一个纹理贴图,左边的纹理贴满了整个球,右边的只贴了球的局部(u的(0 ~ 1)对应φ的(0 ~ φmax), v的(0 ~ 1)对应θ的(θmin ~ θmax))。


    下面看下Sphere Class的构造函数和成员变量,它继承Shape Class,简单看下它的代码:

    class Sphere : public Shape {
      public:
        // Sphere Public Methods
        Sphere(const Transform *ObjectToWorld, const Transform *WorldToObject,
               bool reverseOrientation, Float radius, Float zMin, Float zMax,
               Float phiMax)
    
        //省了...
    
      private:
        // Sphere Private Data
        const Float radius;
        const Float zMin, zMax;
        const Float thetaMin, thetaMax, phiMax;
    };
    

    从构造函数的参数和成员变量可以看出,这些都是构造一个球面(或局部球面)的必要参数,比较简单略过不提。

    3.2.2 Intersection Tests(相交测试)

    首先看下球面相交测试的接口,如下:

    bool Sphere::Intersect(const Ray &r, Float *tHit, SurfaceInteraction *isect,
                           bool testAlphaTexture) const {
                           //省略...
    }
    

    返回值表示球和光线是否有交点,如果有交点,则会返回对应ray的t值(tHit),以及交点的表面的信息(SurfaceInteraction),testAlphaTexture参数还不知道干嘛的,先忽略。

    ray和球面的相交测试因为球的中心位于原点而变得较简单。但前提是先要把ray先转换到球的物体坐标系中。代码如下:

    Ray ray = (*WorldToObject)(r, &oErr, &dErr);
    

    代码中的oErr和dErr是变换计算产生的误差,具体可以看3.9节的浮点型算法(自己看了下感觉挺复杂的有时间再研究吧...)。

    接下来,因为中心在原点,半径为r的球的隐式表达式为:

    x^2 + y^2 + z^2 − r^2 = 0

    而ray的公式为

    r(t) = o + t\vec{d}

    将光线公式代入球面公式,得到

    (o_x + td_x)^2 + (o_y + td_y)^2 + (o_z + td_z)^2 = r^2

    除了t之外其他系数都是已知的。我们展开公式,并将这些系数归纳,这是一个关于t的二次方程,

    at^2 + bt + c = 0

    其中
    a = d_x^2 + d_y^2 + d_z^2
    b = 2 (d_xo_x + d_yo_y + d_zo_z)
    c = o_x^2 + o_y^2 + o_z^2 - r^2

    求解这个二次方程,自己推算了一波(十几年没解过方程了...)


    结果有三种可能,无解,一个解,两个解,对应ray和球不相交,相切,有两个交点,如下图(出自《Ray Tracing in One Weekend》)


    pbrt工程中用Quadratic()工具函数来解二元一次方程,注意它忽略了相切的情况(可能是考虑浮点型计算结果等于0.0的机会极少吧),若不想交则返回false,若相交则返回两个交点对应的t0和t1值,且t0小于t1。

    得到t0和t1后,还要考虑许多的细节,主要是ray自身的t的范围是[0,tmax],还有如果是局部球面,还要另外计算,这个书中有详述,这里不写了。

    得到交点后,继续就出对应的u,v值,略过不提。

    下面是求在交点处表面的一些重要信息,并利用它们构造SurfaceInteraction对象返回。主要有该交点的表面偏导数 ∂p/∂u, ∂p/∂v,和法线偏导数∂n/∂u,∂n/∂v。偏导数,也可以说是变化率,比如
    ∂p/∂u实际上就是点p在u方向上的变化率,但是要注意的是其实p和u分别是在不同的空间坐标中的,p是在球面自身的物体空间,是3D空间,而uv实际上在范围[0,1]的2D纹理空间,如下图:


    可以把p对u的变化率理解为p在球面的纬度方向上的变化率,v对应是经度方向。p是一个向量,即p(px, py, pz),很容易可以知道在纬度方向z坐标是无变化的,因此∂pz/∂u为0。另外书中以∂px/∂u为例推导了如何求解.我直接截了书上的图:


    经过化简和咕嘟咕嘟...,最后得到


    3.2.3 法线向量的偏导数

    法线向量的偏导数∂n/∂u,∂n/∂v的求法过程就复杂多了,该怂的时候就要怂,跳过就好,需要就直接抄公式。

    后面的3.3~3.5节讲 圆柱,碟形,圆锥,双曲面等其他图形接口,与球面类似,不再记录。

    下面重点记录下三角形。

    3.6 三角形网格

    在计算机图形学中,三角形是用的最多的图形。。复杂的场景可以用成千上万的三角形来建模,来展现很好的细节效果。如下图的模型包含了400万个三角形。

    通常为了高效使用内存,会将整个三角形网格的顶点存放在一个数组里,并使用另一个数组存放每个三角形的顶点偏移。

    对于比较紧凑的三角形网格,顶点和面的数量关系可以近似认为是(书中使用欧拉-庞加莱方程来证明,不过我没怎么看懂...)

    V ≈ 2F .

    也就是说,顶点是数量约等于面的数量的两倍。因为每个面和三个顶点关联,所有顶点总共会(平均)被关联6次(想象一下6个三角形组成一个正六边形,六边形的中心)。因此,当顶点被共享时,分摊下来每个三角形需要12个字节来存储偏移值(3个 4字节的32位整型),加上一个顶点大小的一半也就是6个字节(,假设一个顶点是由3个4字节的浮点型组成,也就是一个顶点原本需要12个字节)--也就是每个三角形占18个字节。而如果是直接存储顶点,那需要36个字节。当存在表面法线和纹理坐标时,这种方式的效果将更好。

    TriangleMesh class的代码简略如下:

    struct TriangleMesh {
        // TriangleMesh Public Methods
        TriangleMesh(const Transform &ObjectToWorld, int nTriangles,
                     const int *vertexIndices, int nVertices, const Point3f *P,
                     const Vector3f *S, const Normal3f *N, const Point2f *uv,
                     const std::shared_ptr<Texture<Float>> &alphaMask,
                     const std::shared_ptr<Texture<Float>> &shadowAlphaMask,
                     const int *faceIndices);
    
        // TriangleMesh Data
        const int nTriangles, nVertices;  //三角形数量,顶点数量
        std::vector<int> vertexIndices;   //索引(偏移值)数组
        std::unique_ptr<Point3f[]> p;     //顶点数组
        std::unique_ptr<Normal3f[]> n;    //法线数组
        std::unique_ptr<Vector3f[]> s;    //切线数组
        std::unique_ptr<Point2f[]> uv;    //uv数组
        std::shared_ptr<Texture<Float>> alphaMask, shadowAlphaMask; //还没用过,估计是用来做如半透明,阴影等效果的mask
        std::vector<int> faceIndices;     //面的索引
    };
    

    看到这个类想必熟悉图形API的接口的人都会觉得很眼熟,就是我们用opengl画三角形时都会定义的数组啊!

    3.6.1 三角形

    Triangle class也实现Shape接口。它代表一个单一的三角形。看下它的成员变量:

    class Triangle : public Shape {
      public:
      //省略...
      private:
        // Triangle Private Data
        std::shared_ptr<TriangleMesh> mesh;
        const int *v;
        int faceIndex;
    };
    

    如代码所示,Triangle存储很少的数据--只有一个指向含有该三角形的TriangleMesh的指针,以及一个指向它的三个顶点索引的指针。从它的构造函数可以看出:

    Triangle(const Transform *ObjectToWorld, const Transform *WorldToObject,
                bool reverseOrientation,
                const std::shared_ptr<TriangleMesh> &mesh, int triNumber)
           : Shape(ObjectToWorld, WorldToObject, reverseOrientation),
             mesh(mesh) {
           v = &mesh->vertexIndices[3 * triNumber];
    }
    

    需要注意的是v是一个指向顶点索引的指针.

    接下来讲了CreateTriangleMesh(), ObjectBound(),WorldBound()等接口,较简单不记录了。

    3.6.2 三角形的Intersection

    三角形的相交测试也是实现Shape的Intersect接口,如下:

    bool Triangle::Intersect(const Ray &ray, Float *tHit,
    SurfaceInteraction *isect, bool testAlphaTexture) const { 
       //略...
       }
    

    在pbrt中三角形和ray的相交测试方法比较有意思,首先将当前世界空间坐标变换到一个另一个特殊空间坐标(暂且叫相交坐标),在该坐标下,ray的起点位于原点,ray的方向指向+z方向。三角形的顶点也变换到相交坐标下,然后再进行相交测试。这样相交测试会更加简单,例如,交点的x和y坐标必定为0。另外的优势在3.9.3节中提到(还没研究)。

    计算世界坐标到相交坐标具体有三步:平移T,置换P,和切变S。具体实现没有将三个变换分别求出再计算他们的聚合变换矩阵M=SPT, 而是在每一步直接变换顶点,这样更加高效。

    首先看第一步,T矩阵很容易得出
    T = \left[ \begin{matrix} 1 & 0 & 0 & -o_x \\ 0 & 1 & 0 & -o_y \\ 0 & 0 & 1 & -o_z \\ 0 & 0 & 0 & 1 \end{matrix} \right]

    我们将这个变换作用到三角形的三个顶点,代码如下:

        // Translate vertices based on ray origin
        Point3f p0t = p0 - Vector3f(ray.o);
        Point3f p1t = p1 - Vector3f(ray.o);
        Point3f p2t = p2 - Vector3f(ray.o);
    

    第二步,将ray的方向向量的绝对值最大的一个维度,作为变换后的z轴。另外的两个维度则随意放置到变换后的x轴和y轴。这样可以确保变换后+z轴方向一定是非0的。
    例如,如果ray的方向最大的维度是x轴,那么置换矩阵为:
    T = \left[ \begin{matrix} 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 1 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 \end{matrix} \right]
    这样看起来不够明显,可以把它和一个向量相乘
    T * V = \left[ \begin{matrix} 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 1 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 \end{matrix} \right] \left[ \begin{matrix} d_x \\ d_y \\ d_z \\ 0 \end{matrix} \right] = \left[ \begin{matrix} d_y \\ d_z \\ d_x \\ 0 \end{matrix} \right]

    这样可以看出,这个置换矩阵的作用其实就是把dx,dy,dz交换了下位置,dx最大因此它被放在了z轴的位置。下面是代码:

        // Permute components of triangle vertices and ray direction
        int kz = MaxDimension(Abs(ray.d));
        int kx = kz + 1;
        if (kx == 3) kx = 0;
        int ky = kx + 1;
        if (ky == 3) ky = 0;
        Vector3f d = Permute(ray.d, kx, ky, kz);
        p0t = Permute(p0t, kx, ky, kz);
        p1t = Permute(p1t, kx, ky, kz);
        p2t = Permute(p2t, kx, ky, kz);
    

    第三部,用一个切变矩阵把ray变换到+z轴上:
    S = \left[ \begin{matrix} 1 & 0 & -d_x/d_z & 0 \\ 0 & 1 & -d_y/d_z & 0 \\ 1 & 0 & 1/d_z & 0 \\ 0 & 0 & 0 & 1 \end{matrix} \right]

    要看到它的效果最好的方法就是将它和方向向量\left[ \begin{matrix} d_x & d_y & d_z & 0 \end{matrix} \right] ^ T相乘,你会神奇的发现结果就是[0, 0, 1, 0] !

    现在,只把三角形顶点的x和y坐标切变,我们可以等到判断完是否相交后,再把顶点的z坐标切变(因为判断相交不需要顶点的z坐标)。代码如下:

        // Apply shear transformation to translated vertex positions
        Float Sx = -d.x / d.z;
        Float Sy = -d.y / d.z;
        Float Sz = 1.f / d.z;
        p0t.x += Sx * p0t.z;
        p0t.y += Sy * p0t.z;
        p1t.x += Sx * p1t.z;
        p1t.y += Sy * p1t.z;
        p2t.x += Sx * p2t.z;
        p2t.y += Sy * p2t.z;
    

    接下来的任务就是ray从原点出发,沿着+z方向,看它是否和变换后的三角形相交。这个问题等同于一个2D问题,也就是只考虑xy坐标就行,判断(0,0)是否处在三角形三个顶点的xy坐标之内。如下图:


    为了理解相交算法如何工作,首先回忆2.5节中,两个向量的叉乘得出以它们为边的平行四边形的面积。在2D中,向量ab,面积就是

    a_xb_y - b_xa_y

    三角形的面积是它的一般。因此,在2D中,顶点为p0,p1,p2的三角形的面积是(原书的公式有点小错误)

    \frac{1}{2} \left[ (p_1x - p_0x)(p_2y - p_0y) - (p_2x - p_0x)(p_1y - p_0y) \right]

    应该注意到,叉乘的结果是有正负符号的,也就是说得出平行四边形或三角形的面积都是有符号的。如下图:


    p0和p1是三角形的两个顶点,如果第三个顶点在向量p0p1的左边,那三角形的面积就为正,在右边则为负。我们可以用这个三角形面积特性定义一个有符号边界函数(a signed edge function),假设第三个点是p,那么这个signed edge function为:


    通过这个特性,如果一个点对于三角形的三个边的edge function的值的符号都相同,那么说明该点相对三条边都在同一侧(注意三条边是向量,并且是首尾相连的向量),那么必定处于三角形的内部。

    多亏前面的坐标转换,我们要测试的点p是(0, 0),因此可以简化公式,对于一条edge系数e0,有:


    同理可以算出e1,e2.代码如下:

        // Compute edge function coefficients _e0_, _e1_, and _e2_
        Float e0 = p1t.x * p2t.y - p1t.y * p2t.x;
        Float e1 = p2t.x * p0t.y - p2t.y * p0t.x;
        Float e2 = p0t.x * p1t.y - p0t.y * p1t.x;
    

    得到三个edge function的值后,第一步,我们首先判断这三个值的符号是否相同,若不同则表示没有交点;第二步,如果这三个值的和为0,说明ray非常接近三角形的边缘,我们也认为是没有交点。代码如下:

        // Perform triangle edge and determinant tests
        if ((e0 < 0 || e1 < 0 || e2 < 0) && (e0 > 0 || e1 > 0 || e2 > 0))
            return false;
        Float det = e0 + e1 + e2;
        if (det == 0) return false;
    

    接下来考虑如何求ray的t值。因为ray是从原点开始,是单位长度,且是沿着+z轴,因此交点的z坐标的值正好等于参数值t!为了求交点的z值,首先需要把三角形的三个顶点的z值进行切变变换(上面还没做)。然后,利用重心坐标对三个顶点进行插值就可以求得交点的z值。这些重心坐标如下:

    b_i = \frac{e_i}{e_0 + e_1 +e_2}

    这个插值z由下式得到:

    z = b_0z_0 + b_1z_1 + b_2z_2

    代码如下:

        // Compute scaled hit distance to triangle and test against ray $t$ range
        p0t.z *= Sz;
        p1t.z *= Sz;
        p2t.z *= Sz;
        Float tScaled = e0 * p0t.z + e1 * p1t.z + e2 * p2t.z;
        if (det < 0 && (tScaled >= 0 || tScaled < ray.tMax * det))
            return false;
        else if (det > 0 && (tScaled <= 0 || tScaled > ray.tMax * det))
            return false;
    
        // Compute barycentric coordinates and $t$ value for triangle intersection
        Float invDet = 1 / det;
        Float b0 = e0 * invDet;
        Float b1 = e1 * invDet;
        Float b2 = e2 * invDet;
        Float t = tScaled * invDet;
    

    (重心坐标系的知识可以参看《Fundamentals of Computer Graphics》第2章相关章节)

    最后求讲下∂p/∂u 和 ∂p/∂v,因为三角形是平面,所以只要给定了三角形在3D世界坐标系的顶点坐标和在2D uv坐标系上的uv坐标,所有对于三角形平面上的所有点,∂p/∂u 和 ∂p/∂v都是一样的。直接截了书上的求解过程:


    因为考虑许多误差的细节,所以代码会比公式稍复杂,不再记录了,需要再直接看书吧。

    相关文章

      网友评论

          本文标题:pbrt笔记--第三章 Shapes

          本文链接:https://www.haomeiwen.com/subject/qgrsbctx.html