1、第11章 纹理为了把纹理引进材质模型,我们现在介绍一组接口和类。回忆一下,第10章中所介绍的材质都基于描述其特征的参数(漫反射率,光泽度,等等)。因为真实世界 中材质的性质在表面上是变化的,所以也有必要以同样的方式来描述这些纹理样式(pattern)。在pbrt中,我们对纹理进行抽象化,使得样式的生成方 式跟材质的实现相分开,从而很容易将它们任意组合,进而更容易地创建千变万化的外观。在pbrt中,纹理是一个非常一般化的概念:它是将点从一个域(例如,一个表面的(u,v)参数空间,或者是(x,y,z)物体空间)映射到另一个域(如 光谱值,或实数)的函数。大量的纹理实现以插件的方式提供给用户使用。例
2、如,pbrt有一个纹理,它可以表示返回常量的零维函数,其目的就是描述参数值处 处相同的表面。图像映射纹理是一个关于(s,t)的二维函数,它用像素值的二维数组来计算给定点的值(见11.4节)。pbrt甚至有基于其它纹理函数来 计算函数值的纹理函数。在最后的图像结果中,纹理可以是产生高频变化的一个源头。如图,(a)是每个像素采样一次的有严重走样的图像;(b)是球顶部的放大,可以看出相邻图像采样位置之间的高频细节;(c)是利用本章的反走样技术的效果图。虽然可以用第7章的非均匀采样技术来降低这种走样的视觉影响,但是更好的解决方案是,按照其采样速率来实现摆脱其中的高频内容的纹理函数。对于许多纹理函数而言
3、,对其内容进行良好的近似计算并进行反走样并不困难,在效率上却比加大采样速率的方法好许多。本章第1节将讨论纹理走样问题和一般性的解决方法。然后我们将描述基本的纹理接口,并用几个简单的纹理函数来介绍其使用方法。本章的其余部分将介绍更复杂一些的不同的纹理实现,其中使用了不同的纹理反走样技术。11.1 采样和反走样第7章介绍的采样令人有受挫感,因为我们一开始就知道走样问题是无解的。不管图像采样频率有多高,几何边界和硬阴影(hard shadows)的无限高频内容肯定会在最终图像里产生走样。幸运地是,对于纹理而言,事情并非那么不可救药:要么有一个很方便的纹理函数的解析式,我们 有可能在采样之前就可以去掉
4、高频内容;要么有可能通过仔细的求值来避免高频内容。就象本章所做的那样,如果在纹理实现中认真地解决好这个问题,只需在一个 像素上取一个采样就可以渲染出没有纹理走样的图像。为了在纹理函数中去除走样,必须解决下面两个问题:1. 必须计算出纹理空间里的采样速率。我们可以从图像分辨率和像素采样速率得出屏幕空间的采样速率,但是在这里我们要确定场景中表面的采样速率,从而求解到纹理函数的采样速率。2. 有了纹理采样速率,就要应用采样理论来指导纹理值的计算,使得频率变化不得高于采样速率所表达的范围(例如,去除超出Nyquist极限的高频)。本节的其余部分将要解决这两个问题。11.1.1 求解纹理采样速率在场景中
5、的一个表面上,假定有一个任意的关于位置的纹理函数T(p)。如果我们忽略由可见性引起的复杂性(即有可能有其它物体挡住了图像采样附近的表面, 或者表面在图像平面上范围有限),这个纹理函数可以被表示为图像平面上点(x,y)的函数T(f(x,y),其中f(x,y)是将图像点映射到表面上的 函数。这样T(f(x,y)就给出了在像素(x,y)上的纹理函数值。我们举一个简单的例子:考虑一个2D纹理函数T(s,t),将该纹理施用于一个垂直于z轴的四边形(0,0,0),(1,0,0),(1,1,0), (0,1,0)。如果正交投影相机的朝向是z轴的反方向,使得四边形正好填满图像平面,并且,如果四边形上的点p有下
6、列公式映射到2D (s,t): s = px, t = py那么就很容易得出(s,t)和屏幕上像素(x,y)的关系式: s = x / xr,t = y/yr其中图像总分辨率是(xr, yr)。这样,如果在图像平面上的采样间隔是一个像素,那么在(s,t)纹理参数空间中的采样间隔就是(1/ xr, 1/yr),并且纹理函数必须去除任何高于采样速率所能表达的范围的频率。这个像素坐标和纹理坐标之间的关系,亦即它们的采样速率之间的关系,是决定纹理函数中最大频率内容的关键信息。再给一个稍微复杂一些的例子:给定一个在顶 点处有纹理(s,t)坐标的三角形,并用透视投影来观察它,就可以解析地得到图像平面上采样
7、点之间的关于s和t的差值。这是图形硬件进行基本的纹理映射反 走样的基础。对于更复杂的几何体、相机投影模型和纹理坐标映射方式,确定图像位置和纹理参数值之间的关系就更加困难。幸运地是,对于纹理反走样而言,我们无需对任意的 (x,y)求函数f(x,y)的值,但是却需要找到在图像给定的点上的图像采样位置的变化值跟相应的纹理采样位置变化值的关系。这个关系可以用该函数的偏 导数f/x, f/y给出。例如,我们用下面的近似公式得到f值的一阶近似: f(x,y) f(x,y) + (x-x) f/x + (y-y) f/x如果这些偏导数对于距离x-x和y-y而言变化缓慢,那么这就是很合理的近似公式。更重要的是
8、,这些偏导数值分别给出了当像素在x和y方向上偏移一个位置时的纹理采样位置的变化值,也就得到了纹理采样速率。例如,对于上面的四边形,s/x = 1/xr, s/y = 0, t/x = 0, t/y = 1/yr。对这些偏导数求值的关键位于第2.5.1节介绍的RayDifferential结构中。在Scene:Render()函数中,每条相机光线都要初始 化这个结构,它不仅包括被追踪的光线,还有两条额外的光线,其中的一条从相机光线位置上水平地偏移一个像素,另一条垂直地偏移一个像素。所有的几何光线求 交例程只需要主相机光线(main camera ray),而忽略了两条辅助光线。现在我们用这两条辅
9、助光线来估算从图像位置到世界空间位置的函数p(x,y)的偏导数p/x、p/y,还有从(x,y)到(u,v)参数坐标的函 数u(x,y)和v(x,y)的偏导数u/x、u/x、v/y和v/y。在第11节,我们将会看到如何利用这些值来计算基于p或 (u,v)的任意量的屏幕空间中的偏导数和这些量的采样速率。在交点处的这些偏导数值被存放在DifferentialGeometry结构中。注意它们 都被声明为mutable,因为它们是在一个以const型DifferentialGeometry对象为参数的函数中被赋值的。 += mutable Vector dpdx, dpdy; mutable float
10、 dudx, dvdx, dudy, dvdy; += dudx = dvdx = dudy = dvdy = 0;DifferentialGeometry:ComputeDifferentials()函数计算这些值。在Material:GetBSDF() 被调用之前,该函数由Intersection:GetBSDF()来调用,所以在进行材质求值时,就可以使用这些值了。因为并不是所有被系统追踪的光 线都有光线微分信息,所以在计算之前,我们必须检查RayDifferential中的hasDifferential值。如果该值为false,那么这 些偏导数值都被设为0。 += void Differ
11、entialGeometry:ComputeDifferentials( const RayDifferential &ray) const if (ray.hasDifferentials) else dudx = dvdx = 0.; dudy = dvdy = 0.; dpdx = dpdy = Vector(0,0,0); 计算这些估算值的关键是:就着色点处的采样速率而言,我们假定表面是局部平坦的。在实际应用中,这是一个合理的近似,也很难做到更好的结果。因为光线追踪 程序是一个点采样技术,所以我们没有关于光线之间的额外信息。对于高度弯曲的表面或者轮廓而言,这个近似就不成立了,虽然在实际
12、应用中它很少产生显著的错 误。为了实现这个近似计算,我们需要得到通过(主光线跟表面的)交点的表面的切平面,其方程为: ax + by + cz + d = 0其中a = nx, b = ny, c = nz, d = - (n . p)。然后我们计算辅助光线rx和ry跟该平面的交点px, py。如图:利用前向差分技术和该点处的表面偏导数值,可以得到: p/x px - p,p/y py - p因为微分光线在各个方向上偏移一个像素,所有就没有必要除以差值了,因为=1。 = dpdx = px - p; dpdy = py - p; 我们用光线-平面求交算法得出交点的t值: t = ( -(a,b
13、,c) . o) + d) /(a,b,c) . d我们先计算出平面的系数d。没有必要计算系数a,b,c,因为它们是dg.nn的分量。 = float d = -Dot(tin, Vector(p.x, p.y, p.z); Vector rxv(ray.rx.o.x, ray.rx.o.y, ray.rx.o.z); float tx = -(Dot(nn, rxv) + d) / Dot(nn, ray.rx.d); Point px = ray.rx.o + tx * ray.rx.d; Vector ryv(ray.ry.o.x, ray.ry.o.y, ray.ry.o.z); fl
14、oat ty = -(Dot(nn, ryv) + d) / Dot(nn, ray.ry.d); Point py = ray.ry.o + ty * ray.ry.d;我们可以利用px和py来求得它们相应的(u,v)坐标,其方法是利用两个事实,第一个事实是表面偏导数p/u和p/v所形成一个坐标系(不一定是正交坐标系)第二个是两个辅助交点在该坐标系下的坐标等于它们在(u,v)空间的参数化坐标。如图:给定了一个点p,我们可以用下列公式计算它在这个坐标系下的位置: p = p + u p/u + v dv p/v或者 解这个线性方程组就可以得到两个辅助交点在屏幕空间内的偏导数u/x, v/x,
15、u/y, v/y。这个方程组有三个方程两个未知数,也就是说它是过约束的(overcontrained)。我们必须仔细对待,因为有一个方程可能是退化的 -例如,如果p/u和p/v都在xy平面上,则它们的z值为0,那么第三个方程就是退化的。为了处理这种情况,我们只需两个方程来解这个方程 组。我们要做的是挑选不会导致方程组退化的两个方程。一个简单的方法是取p/u和p/v的叉积,观察哪一个坐标有最大值,然后选择另外两个。因为 它们的叉积已经在nn中了,这就很直截了当了。即使有了这些处理,仍然会出现方程组无解的情况(通常是两个偏导数无法形成坐标系的情况)。这时,我们只能 返回任意值了。= if (Sol
16、veLinearSystem2x2(A, Bx, x) dudx = x0; dvdx = x1; else dudx = 1.; dvdx = 0.; if (SolveLinearSystem2x2(A, By, x) dudy = x0; dvdy = x1; else dudy = 0.; dvdy = 1.; = float A22, Bx2, By2, x2; int axes2; if (fabsf(nn.x) fabsf(nn.y) & fabsf(nn.x) fabsf(nn.z) axes0 = 1; axes1 = 2; else if (fabsf(nn.y) fabs
17、f(nn.z) axes0 = 0; axes1 = 2; else axes0 = 0; axes1 = 1; = A00 = dpduaxes0; A01 = dpdvaxes0; A10 = dpduaxes1; A11 = dpdvaxes1; Bx0 = pxaxes0 - paxes0; Bx1 = pxaxes1 - paxes1; By0 = pyaxes0 - paxes0; By1 = pyaxes1 - paxes1;11.1.2 对纹理函数的滤波对于给定的纹理采样速率,我们必须消除纹理函数中超过Nyquist极限的频率。其目标就是尽可能少地使用近似方法来计算“理想的纹理
18、重采样” (ideal texture resampling)的结果,也就是说,为了计算没有走样的T(f(x,y),我们必须先对之进行带宽限制(band-limit),对之使用 sinc滤波器做卷积计算,来去除超过Nyquist极限的频率:被带宽限制后的函数再跟以屏幕上(x,y)点为中心的像素滤波器g(x,y)做卷积,就得到我们所需要的纹理函数:这就是纹理被投影到屏幕上的理想的理论值。在实际应用中,可以对上述过程进行许多简化而只产生很少的视觉质量上的损失。例如,在宽带限制这一步,可以使用方盒滤波器而完全省略掉第二步,其效果等同 于用方盒滤波器作为像素滤波器,这样一来,反走样的工作是在纹理空间中
19、进行的,从而极大地简化了实现。第11.4.4节介绍的EWA滤波器算法是个显著的 例外,因为它没有使用所有这些简化方法。虽然方盒滤波器有许多缺点,却在许多情况下产生了令人满意的效果。它非常容易使用,因为这只是在恰当的区域求纹理函数的平均值。从直觉上讲,这是关于纹理 滤波问题的一个合理的解决办法,可以直接用于许多纹理函数。实际上,本章的剩余部分常常使用一个方盒滤波器对采样的纹理函数值求平均值,并使用“滤波器区 域”(filter region)这个非正式词汇来表述要做平均计算的区域。这也是对纹理函数进行滤波的最常用的方法了。另一个选择是来自对于理想sinc滤波器效果的一个观察:它让低于Nyquis
20、t极限的频率毫发无损地通过,而去掉了高于Nyquist极限的频率。因 此,如果我们知道了纹理函数的频率内容(例如,它是几个已知频率内容的叠加),那么我们就可以将高频项替换成它们的平均值,这样,我们实际上是做sinc 前置滤波的工作。这个方法被称为截取(Clamping),是第11.6节中基于噪声函数的纹理的反走样的基础。最后,对于上述技术都不好用的那些纹理函数,最后的一招就是超采样(supersampling)-即在主求值点的附近进行多个位置进行函数求值,这 样就加大了纹理空间中的采样速率。如果用盒滤波器对这些采样值进行过滤,就等同于求函数的平均值。当纹理函数太复杂时,这个方法是很费时的,跟图
21、像采样一 样,需要大量的采样来消除走样。虽然这是强力(brute force)解决方案,但仍然比增加图像采样速率的方法更有效,因为它节省了追踪更多光线的开销。11.1.3 镜面反射和透射所需的光线微分信息既然我们已经了解了光线微分信息对于寻找用于纹理反走样的滤波区域的有效性,我们就可以对之扩展为更有用的方法-对于那些通过镜面反射或折射所间接看到 的物体,也可以用此方法确定纹理空间的采样速率;例如,对于镜子里的物体的走样,也可以跟直接被看到的物体一样被消除掉。Igehy(1999)开发了一 种优雅的技术解决了如何寻找适当的用于镜面反射和折射的微分光线,pbrt也用了这项技术。为了技术在表面交点处
22、的反射或折射的光线微分,我们需要对两条偏置的光线的将被追踪的被反射(或折射)的光线。如图。主光线所对应的新光线是用BSDF计算出来的,我们只需计算rx,ry的出射光线。对于反射和折射,很容易得到每条微分光线的原点。DifferentialGeometry:DifferentialDifferentials() 已经计算出了关于在p/x, p/y图像平面上的(x,y)位置的相应表面位置的偏置量。将这些偏置量加上主光线的交点位置,就可以得到新光线的近似原点位置。 = RayDifferential rd(p, wi); rd.hasDifferentials = true; rd.rx.o = p
23、 + isect.dg.dpdx; rd.ry.o = p + isect.dg.dpdy; 求这些光线的方向有些麻烦。Igehy观察到如果我们知道在图像平面上x和y方向移动一个像素时反射方向i的变化值,就可以利用该值近似地得到偏置光线的方向: i + i / x对于世界空间中表面的法向量和出射方向,理想镜面反射的方向为: i = - o + 2 (o . n) n幸运地是,可以很容易得到该式的偏导数: i / x = ( - o + 2 (o . n) n) /x =- o / x + 2(o . n) n /x + ( o . n)/ x) n )利用点积的性质,有: ( o . n)/
24、x = ( o / x) . n + o . n / x = Vector dndx = bsdf-dgShading.dndu * bsdf-dgShading.dudx + bsdf-dgShading.dndv * bsdf-dgShading.dvdx; Vector dndy = bsdf-dgShading.dndu * bsdf-dgShading.dudy + bsdf-dgShading.dndv * bsdf-dgShading.dvdy; Vector dwodx = -ray.rx.d - wo, dwody = -ray.ry.d - wo; float dDNdx
25、= Dot(dwodx, n) + Dot(wo, dndx); float dDNdy = Dot(dwody, n) + Dot(wo, dndy); rd.rx.d = wi - dwodx + 2 * (Dot(wo, n) * dndx + Vector(dDNdx * n); rd.ry.d = wi - dwody + 2 * (Dot(wo, n) * dndy + Vector(dDNdy * n);我们可以利用类此的方法对镜面透射的方向的方程求微分,从而得到关于透射方向的微分差值的等式。这里就不介绍了。11.2 纹理坐标的生成本章所介绍的纹理都是以一个以二维或三维坐标为参数
26、并返回一个纹理值的函数。在有些情况下,我们会找到浅显的方法来选择纹理坐标。例如,对于第三章中的参数曲面,二维的(u,v)参数就是自然的选择,而对于所有的曲面,着色点就是一个三维坐标的自然选择。但也常有无法参数化的复杂曲面,或者自然的参数化形式并不满足要求。例如,靠近球面极点的(u,v)值会变得极度变形。还有,对于一个任意的细分曲面,没 有一个简单而又通用的方法,可以对整个0,1x0,1空间进行纹理赋值,能够具备连续性又不会产生变形。实际上,创建变形很小的光滑的复杂网格参 数化方式是计算机图形学中的一个很活跃的研究领域。本节先介绍两个抽象基类: TextureMapping2D和TextureM
27、apping3D,它们提供了计算二维或三维纹理坐标的接口。然后,我们实现几个使用这个接口的标准 映射。Texture的实现存放了一个指向一个2D或3D映射的函数,利用它来计算每个点上的纹理坐标。这样就很容易加入新的映射而无需修改所有的纹理实 现,并且同一表面的不同纹理可以使用不同的映射。在pbrt中,我们用(s,t)来表示2D纹理坐标,使其跟表面的参数(u,v)相区别。TextureMapping2D基类有一个单一的函数,TextureMapping2D:Map(),它以一个着色点上的 DifferentialGeometry为参数,返回纹理坐标(s,t)。同时,它还返回s,t的关于像素坐标x
28、,y的变化值 dsdx,dtdx,dsdy,dtdy,纹理类可以用这些值可以确定(s,t)的采样速率并进行滤波。 = class COREDLL TextureMapping2D public: ; = virtual void Map(const DifferentialGeometry &dg, float *s, float *t , float *dsdx, float *dtdx, float *dsdy, float *dtdy) const = 0;11.2.1 2D(u,v)映射最简单的纹理映射就是利用DifferentialGeometry中的二维参数(u,v)坐标计算纹理坐
29、标,我们可以使用用户所提供的值来对它们进行偏移和比例变换。 += class COREDLL UVMapping2D : public TextureMapping2D public: private: float su, sv, du, dv; ; = UVMapping2D:UVMapping2D(float _su, float _sv, float _du, float _dv) su = _su; sv = _sv; du = _du; dv = _dv; 其中的比例变换和偏移计算很简单: += void UVMapping2D:Map(const DifferentialGeome
30、try &dg, float *s, float * t , float *dsdx, float *dtdx, float *dsdy, float *dtdy) const *s = su * dg.u + du; *t = sv * dg.v + dv; 我们也可以容易地求得关于u,v变化的s,t的微分变化值。利用链式法则: s/x = u/x . s/u + v/x . s/v由映射的方法得知: s = su u + du所以: s/u = su,s/v = 0故而: s/x = su. u / xy方向的偏导数也可类似求得。 = *dsdx = su * dg.dudx; *dtdx
31、 = sv * dg.dvdx; *dsdy = su * dg.dudy; *dtdy = sv * dg.dvdy;11.2.2 球面映射另一个映射方法将物体包上一个球面。每个点都是在从球心到球面的方向上被投影到物体上的。我们要用到球面的(u,v)映射。phericalMapping2D存有一个变换,在进行这样的映射之前,先要对点进行变换。这样做的目的是为了让映射球面可以任意地定位。 += class COREDLL SphericalMapping2D : public TextureMapping2D public: private: void sphere(const Point &
32、P, float *s, float *t) const; Transform WorldToTexture; ; += void SphericalMapping2D:Map(const DifferentialGeometry &dg, float *s, float *t , float *dsdx, float *dtdx, float *dsdy, float *dtdy) const sphere(dg.p, s, t) ; 下面的这个工具函数用来计算单个点的映射: += void SphericalMapping2D:sphere(const Point &p, float *s
33、, float *t) const Vector vec = Normalize(WorldToTexture(p) - Point(0,0,0); float theta = SphericalTheta(vec); float phi = SphericalPhi(vec); *s = theta * INVPI; *t = phi * INV_TW0PI; 我们可以用链式法则来计算纹理坐标微分,但是我们这里用前向差分近似公式来演示另一种方法。回忆一下,DifferentialGeometry存放了屏幕空间的偏导数p/x和p/y。如果s坐标是有某个函数fs(p)得到的,则有: s/x (f
34、s(p + p/x) - fs(p) / 当距离趋于0时,就得到在p点的偏导数。另一个需要注意的细节是球面映射公式有个不连续点,当t = 1时,有一个不连续的缝,即t坐标在这里立即跳回到0值。我们可以检查利用前向差分所算出的值,看它是否大于0.5,然后做相应的调整。 = float sx, tx, sy, ty; const float delta = .1f; sphere(dg.p + delta * dg.dpdx, &sx, &tx); *dsdx = (sx - *s) / delta; *dtdx = (tx - *t) / delta; if (*dtdx .5) *dtdx =
35、 1.f - *dtdx; else if (*dtdx .5) *dtdy = 1.f - *dtdy; else if (*dtdy -.5f ) *dtdx = -(*dtdy + 1);11.2.3 柱面映射柱面映射将物体包上一个柱面。它也支持用来定位映射柱面的变换。 += class COREDLL CylindricalMapping2D : public TextureMapping2D public: private: void cylinder(const Point &p,float *s, float *t) const; Transform WorldToTexture
36、; ;柱面映射有和球面映射一样的基本结构,只是映射函数有所不同。这里略去了计算纹理坐标微分的片段。 += void CylindricalMapping2D:Map(const DifferentialGeometry &dg, float *s, float *t , float *dsdx, float *dtdx, float *dsdy, float *dtdy) const cylinder(dg.p, s, t ); += void CylindricalMapping2D:cylinder(const Point &p, float *s, float *t) const Vec
37、tor vec = Normalize(WorldToTexture(p) - Point(0,0,0); *s = (M_PI + atan2f(vec.y, vec.x) / (2.f * M_PI); *t = vec.z; 11.2.4 平面映射另一个经典的映射是平面映射。点被投影到一个平面上,平面的2D参数化形式给出了纹理在该点的坐标。例如,我们可以将点投影到z =0的平面上,从而有纹理坐标s = px, t = py。一般地,我们用两个不平行的向量vs,vt和偏置值ds,dt来定义这个平面。纹理坐标是根据该点在平面坐标系下给出的,我们计算从该点到原点的向量和 vs,vt的点积,在加上偏置值来得到纹理坐标。例如上面的平面z=0的例子,就有vs = (1,0,0), vt = (0,1,0), ds = dt = 0。 += class COREDLL PlanarMapping2D : public TextureMapping2D public: private: Vector vs, vt;