PBRT阅读:第十一章 纹理 第11.1-11.4节
http://www.opengpu.org/forum.php?mod=viewthread&tid=5817
第11章 纹理
为了把纹理引进材质模型,我们现在介绍一组接口和类。回忆一下,第10章中所介绍的材质都基于描述其特征的参数(漫反射率,光泽度,等等)。因为真实世界中材质的性质在表面上是变化的,所以也有必要以同样的方式来描述这些纹理样式(pattern)。在pbrt中,我们对纹理进行抽象化,使得样式的生成方式跟材质的实现相分开,从而很容易将它们任意组合,进而更容易地创建千变万化的外观。
在pbrt中,纹理是一个非常一般化的概念:它是将点从一个域(例如,一个表面的(u,v)参数空间,或者是(x,y,z)物体空间)映射到另一个域(如光谱值,或实数)的函数。大量的纹理实现以插件的方式提供给用户使用。例如,pbrt有一个纹理,它可以表示返回常量的零维函数,其目的就是描述参数值处处相同的表面。图像映射纹理是一个关于(s,t)的二维函数,它用像素值的二维数组来计算给定点的值(见11.4节)。pbrt甚至有基于其它纹理函数来计算函数值的纹理函数。
在最后的图像结果中,纹理可以是产生高频变化的一个源头。如图,(a)是每个像素采样一次的有严重走样的图像;(b)是球顶部的放大,可以看出相邻图像采样位置之间的高频细节;(c)是利用本章的反走样技术的效果图。
虽然可以用第7章的非均匀采样技术来降低这种走样的视觉影响,但是更好的解决方案是,按照其采样速率来实现摆脱其中的高频内容的纹理函数。对于许多纹理函数而言,对其内容进行良好的近似计算并进行反走样并不困难,在效率上却比加大采样速率的方法好许多。
本章第1节将讨论纹理走样问题和一般性的解决方法。然后我们将描述基本的纹理接口,并用几个简单的纹理函数来介绍其使用方法。本章的其余部分将介绍更复杂一些的不同的纹理实现,其中使用了不同的纹理反走样技术。
11.1 采样和反走样
第7章介绍的采样令人有受挫感,因为我们一开始就知道走样问题是无解的。不管图像采样频率有多高,几何边界和硬阴影(hard shadows)的无限高频内容肯定会在最终图像里产生走样。幸运地是,对于纹理而言,事情并非那么不可救药:要么有一个很方便的纹理函数的解析式,我们有可能在采样之前就可以去掉高频内容;要么有可能通过仔细的求值来避免高频内容。就象本章所做的那样,如果在纹理实现中认真地解决好这个问题,只需在一个像素上取一个采样就可以渲染出没有纹理走样的图像。
为了在纹理函数中去除走样,必须解决下面两个问题:
1. 必须计算出纹理空间里的采样速率。我们可以从图像分辨率和像素采样速率得出屏幕空间的采样速率,但是在这里我们要确定场景中表面的采样速率,从而求解到纹理函数的采样速率。
2. 有了纹理采样速率,就要应用采样理论来指导纹理值的计算,使得频率变化不得高于采样速率所表达的范围(例如,去除超出Nyquist极限的高频)。
本节的其余部分将要解决这两个问题。
11.1.1 求解纹理采样速率
在场景中的一个表面上,假定有一个任意的关于位置的纹理函数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有下列公式映射到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)坐标的三角形,并用透视投影来观察它,就可以解析地得到图像平面上采样点之间的关于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而言变化缓慢,那么这就是很合理的近似公式。更重要的是,这些偏导数值分别给出了当像素在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),而忽略了两条辅助光线。
现在我们用这两条辅助光线来估算从图像位置到世界空间位置的函数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 dudx, dvdx, dudy, dvdy;
dudx = dvdx = dudy = dvdy = 0;
DifferentialGeometry::ComputeDifferentials()函数计算这些值。在Material::GetBSDF()被调用之前,该函数由Intersection::GetBSDF()来调用,所以在进行材质求值时,就可以使用这些值了。因为并不是所有被系统追踪的光线都有光线微分信息,所以在计算之前,我们必须检查RayDifferential中的hasDifferential值。如果该值为false,那么这些偏导数值都被设为0。
void DifferentialGeometry::ComputeDifferentials(
const RayDifferential &ray) const {
if (ray.hasDifferentials) {
else {
dudx = dvdx = 0.;
dudy = dvdy = 0.;
dpdx = dpdy = Vector(0,0,0);
}
}
计算这些估算值的关键是:就着色点处的采样速率而言,我们假定表面是局部平坦的。在实际应用中,这是一个合理的近似,也很难做到更好的结果。因为光线追踪程序是一个点采样技术,所以我们没有关于光线之间的额外信息。对于高度弯曲的表面或者轮廓而言,这个近似就不成立了,虽然在实际应用中它很少产生显著的错误。为了实现这个近似计算,我们需要得到通过(主光线跟表面的)交点的表面的切平面,其方程为:
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,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);
float 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, ∂u/∂y, ∂v/∂y。这个方程组有三个方程两个未知数,也就是说它是过约束的(overcontrained)。我们必须仔细对待,因为有一个方程可能是退化的---例如,如果∂p/∂u和∂p/∂v都在xy平面上,则它们的z值为0,那么第三个方程就是退化的。为了处理这种情况,我们只需两个方程来解这个方程组。我们要做的是挑选不会导致方程组退化的两个方程。一个简单的方法是取∂p/∂u和∂p/∂v的叉积,观察哪一个坐标有最大值,然后选择另外两个。因为它们的叉积已经在nn中了,这就很直截了当了。即使有了这些处理,仍然会出现方程组无解的情况(通常是两个偏导数无法形成坐标系的情况)。这时,我们只能返回任意值了。
if (SolveLinearSystem2x2(A, Bx, x)) {
dudx = x[0]; dvdx = x[1];
}
else {
dudx = 1.; dvdx = 0.;
}
if (SolveLinearSystem2x2(A, By, x)) {
dudy = x[0]; dvdy = x[1];
}
else {
dudy = 0.; dvdy = 1.;
}
float A[2][2], Bx[2], By[2], x[2];
int axes[2];
if (fabsf(nn.x) > fabsf(nn.y) && fabsf(nn.x) > fabsf(nn.z)) {
axes[0] = 1; axes[1] = 2;
}
else if (fabsf(nn.y) > fabsf(nn.z)) {
axes[0] = 0; axes[1] = 2;
else {
axes[0] = 0; axes[1] = 1;
}
A[0][0] = dpdu[axes[0]];
A[0][1] = dpdv[axes[0]];
A[1][0] = dpdu[axes[1]];
A[1][1] = dpdv[axes[1]];
Bx[0] = px[axes[0]] - p[axes[0]];
Bx[1] = px[axes[1]] - p[axes[1]];
By[0] = py[axes[0]] - p[axes[0]];
By[1] = py[axes[1]] - p[axes[1]];
11.1.2 对纹理函数的滤波
对于给定的纹理采样速率,我们必须消除纹理函数中超过Nyquist极限的频率。其目标就是尽可能少地使用近似方法来计算“理想的纹理重采样”(ideal texture resampling)的结果,也就是说,为了计算没有走样的T(f(x,y)),我们必须先对之进行带宽限制(band-limit),对之使用sinc滤波器做卷积计算,来去除超过Nyquist极限的频率:
被带宽限制后的函数再跟以屏幕上(x,y)点为中心的像素滤波器g(x,y)做卷积,就得到我们所需要的纹理函数:
这就是纹理被投影到屏幕上的理想的理论值。
在实际应用中,可以对上述过程进行许多简化而只产生很少的视觉质量上的损失。例如,在宽带限制这一步,可以使用方盒滤波器而完全省略掉第二步,其效果等同于用方盒滤波器作为像素滤波器,这样一来,反走样的工作是在纹理空间中进行的,从而极大地简化了实现。第11.4.4节介绍的EWA滤波器算法是个显著的例外,因为它没有使用所有这些简化方法。
虽然方盒滤波器有许多缺点,却在许多情况下产生了令人满意的效果。它非常容易使用,因为这只是在恰当的区域求纹理函数的平均值。从直觉上讲,这是关于纹理滤波问题的一个合理的解决办法,可以直接用于许多纹理函数。实际上,本章的剩余部分常常使用一个方盒滤波器对采样的纹理函数值求平均值,并使用“滤波器区域”(filter region)这个非正式词汇来表述要做平均计算的区域。这也是对纹理函数进行滤波的最常用的方法了。
另一个选择是来自对于理想sinc滤波器效果的一个观察:它让低于Nyquist极限的频率毫发无损地通过,而去掉了高于Nyquist极限的频率。因此,如果我们知道了纹理函数的频率内容(例如,它是几个已知频率内容的叠加),那么我们就可以将高频项替换成它们的平均值,这样,我们实际上是做sinc前置滤波的工作。这个方法被称为截取(Clamping),是第11.6节中基于噪声函数的纹理的反走样的基础。
最后,对于上述技术都不好用的那些纹理函数,最后的一招就是超采样(supersampling)---即在主求值点的附近进行多个位置进行函数求值,这样就加大了纹理空间中的采样速率。如果用盒滤波器对这些采样值进行过滤,就等同于求函数的平均值。当纹理函数太复杂时,这个方法是很费时的,跟图像采样一样,需要大量的采样来消除走样。虽然这是强力(brute force)解决方案,但仍然比增加图像采样速率的方法更有效,因为它节省了追踪更多光线的开销。
11.1.3 镜面反射和透射所需的光线微分信息
既然我们已经了解了光线微分信息对于寻找用于纹理反走样的滤波区域的有效性,我们就可以对之扩展为更有用的方法--对于那些通过镜面反射或折射所间接看到的物体,也可以用此方法确定纹理空间的采样速率;例如,对于镜子里的物体的走样,也可以跟直接被看到的物体一样被消除掉。Igehy(1999)开发了一种优雅的技术解决了如何寻找适当的用于镜面反射和折射的微分光线,pbrt也用了这项技术。
为了技术在表面交点处的反射或折射的光线微分,我们需要对两条偏置的光线的将被追踪的被反射(或折射)的光线。如图。主光线所对应的新光线是用BSDF计算出来的,我们只需计算rx,ry的出射光线。
对于反射和折射,很容易得到每条微分光线的原点。DifferentialGeometry::DifferentialDifferentials()已经计算出了关于在∂p/∂x, ∂p/∂y图像平面上的(x,y)位置的相应表面位置的偏置量。将这些偏置量加上主光线的交点位置,就可以得到新光线的近似原点位置。
RayDifferential rd(p, wi);
rd.hasDifferentials = true;
rd.rx.o = p + 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)/ ∂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 = 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 纹理坐标的生成
本章所介绍的纹理都是以一个以二维或三维坐标为参数并返回一个纹理值的函数。在有些情况下,我们会找到浅显的方法来选择纹理坐标。例如,对于第三章中的参数曲面,二维的(u,v)参数就是自然的选择,而对于所有的曲面,着色点就是一个三维坐标的自然选择。
但也常有无法参数化的复杂曲面,或者自然的参数化形式并不满足要求。例如,靠近球面极点的(u,v)值会变得极度变形。还有,对于一个任意的细分曲面,没有一个简单而又通用的方法,可以对整个[0,1]x[0,1]空间进行纹理赋值,能够具备连续性又不会产生变形。实际上,创建变形很小的光滑的复杂网格参数化方式是计算机图形学中的一个很活跃的研究领域。
本节先介绍两个抽象基类: TextureMapping2D和TextureMapping3D,它们提供了计算二维或三维纹理坐标的接口。然后,我们实现几个使用这个接口的标准映射。Texture的实现存放了一个指向一个2D或3D映射的函数,利用它来计算每个点上的纹理坐标。这样就很容易加入新的映射而无需修改所有的纹理实现,并且同一表面的不同纹理可以使用不同的映射。在pbrt中,我们用(s,t)来表示2D纹理坐标,使其跟表面的参数(u,v)相区别。
TextureMapping2D基类有一个单一的函数,TextureMapping2D::Map(),它以一个着色点上的DifferentialGeometry为参数,返回纹理坐标(s,t)。同时,它还返回s,t的关于像素坐标x,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)坐标计算纹理坐标,我们可以使用用户所提供的值来对它们进行偏移和比例变换。
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 DifferentialGeometry &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 / ∂x
y方向的偏导数也可类似求得。
*dsdx = su * dg.dudx;
*dtdx = 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 &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, 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 ≈ (fs(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 = 1.f - *dtdx;
else if (*dtdx < -.5f ) *dtdx = -(*dtdx +1);
sphere(dg.p + delta * dg.dpdy, &sx, &tx);
*dsdy = (sy - *s) / delta;
*dtdy = (ty - * t ) / delta;
if (*dtdy > .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;
};
柱面映射有和球面映射一样的基本结构,只是映射函数有所不同。这里略去了计算纹理坐标微分的片段。
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 {
Vector 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;
float ds, dt;
};
PlanarMapping2D::PlanarMapping2D(const Vector &_v1,
const Vector &_v2, float _ds, float _dt) {
vs = _v1;
vt = _v2;
ds = _ds;
dt = _dt;
}
void PlanarMapping2D::Map(const DifferentialGeometry &dg,
float *s, float *t , float *dsdx, float *dtdx,
float *dsdy, float *dtdy) const {
Vector vec = dg.p - Point(0,0,0);
*s = ds + Dot(vec, vs);
*t = dt + Dot(vec, vt);
*dsdx = Dot(dg.dpdx, vs);
*dtdx = Dot(dg.dpdx, vt);
*dsdy = Dot(dg.dpdy, vs);
*dtdy = Dot(dg.dpdy, vt);
}
11.2.5 3D映射
我们定义一个TextureMapping3D类来定义生成三维纹理坐标的接口。
class COREDLL TextureMapping3D {
public:
};
virtual Point Map(const DifferentialGeometry &dg,
Vector *dpdx, Vector *dpdy) const = 0;
这个颇为自然的3D映射函数以一个世界空间的点为参数,对之实施一个线性变换。这个变换常常是一个将点变换回体素的物体空间的变换。
class COREDLL IdentityMapping3D : public TextureMapping3D {
public:
IdentityMapping3D(const Transform &x)
: WorldToTexture(x) { }
Point Map(const DifferentialGeometry &dg, Vector *dpdx,
Vector *dpdy) const;
private:
Transform WorldToTexture;
};
因为我们使用的是线性映射,我们可以对位置的偏导数实施同样的映射就可以得到纹理坐标的微分变化值。
Point IdentityMapping3D::Map(const DifferentialGeometry &dg,
Vector *dpdx, Vector *dpdy) const {
*dpdx = WorldToTexture(dg.dpdx);
*dpdy = WorldToTexture(dg.dpdy);
return WorldToTexture(dg.p);
}
11.3 纹理接口和基本纹理
Texture是一个由其求值函数的返回值类型所参数化的模版类。这个设计可以让我们在不同返回值类型的纹理类中重用几乎所有的关于纹理贴图的代码。现在pbrt只用float和Specturm类型的纹理。
template
public:
};
纹理接口的关键在于其求值函数。它返回一个类型T的值。它在求值时所需要的唯一信息就是着色点上的DifferentialGeometry。在本章所介绍的纹理实现中,不同的纹理使用该结构的不同部分来求值。
11.3.1 常量纹理
ConstantTexture不论在哪里求值都返回相同的值。因为它是一个常量函数,我们可以在任何采样速率下对其进行重构,因此无需进行反走样。虽然这个纹理非常简单,但却很有用。有了这个类,所有Material的参数都可以被表示为Texture。例如,一个红色的漫反射物体可以带一个ConstantTexture,它总是返回红色来作为材料的漫反射颜色。这样一来,着色系统就可以总是进行纹理求值来得到某个点的表面性质,从而避免了区分纹理材质和非纹理材质的必要。
template
public:
private:
T value;
};
ConstantTexture(const T &v) { value = v; }
T Evaluate(const Differential Geometry &) const {
return value;
}
11.3.2 比例纹理
利用我们定义的纹理接口很容易在计算一个纹理函数的时候同时使用另一个纹理函数的输出。接口的这个优点很有用处,因为我们可以使用其它类型的纹理定义更通用的纹理操作。ScaleTexture使用两个纹理,并在求值时返回两个函数值的乘积。
template
class ScaleTexture : public Texture
public:
private:
Reference
Reference
};
ScaleTexture(Reference
Reference
tex1 = t1;
tex2 = t2;
}
ScaleTexture忽略了反走样,将反走样的责任放在了两个子纹理的身上,而不会花费精力对其乘积进行反走样。很容易证明有带宽限制的函数的乘积仍然是带宽限制的,乘积的最大频率可以比两个子纹理的最大频率还要大。所以,即使比例(scale)纹理和值(value)纹理都经过了完美的反走样处理,乘积的结果却未必是完美的。幸运地是,比例(scale)纹理常常是常数,只需另一个纹理的反走样即可。
T2 Evaluate(const DifferentialGeometry &dg) const {
return tex1->Evaluate(dg) * tex2->Evaluate(dg);
}
11.3.3 混合纹理
MixTexture类是ScaleTexture的更一般化的一种变形。它使用了三个纹理做为输入:其中两个可以为任何类型,第三个必须返回一个浮点数。这个浮点数纹理被用来对另外两个纹理的线性插值。注意ConstTexture可以被用做这里的浮点数纹理,并起到一种均匀混合的效果;当然也可以使用更复杂的纹理,以一种空间非均匀的方式来进行混合。
template
class MixTexture : public Texture
public:
private:
Reference
Reference
};
MixTexture(Reference
Reference
texl = t1;
tex2 = t2;
amount = amt;
}
为了对混合型纹理进行求值,先对三个纹理求值,然后用所得到的浮点数对另外两个纹理进行线性插值。如何混合量(blend amount, amt)为0,则只需返回第一个第一个纹理的值;如果为1,则返回第二个的值。通常我们假定amt是在0或1之间的,但并不一定必须如此,所以外插也是可能的。跟ScaleTexture一样,这里忽略了反走样,所以这里可能会引起走样。
T Evaluate(const DifferentialGeometry &dg) const {
T t1 = tex1->Evaluate(dg), t2 = tex2->Evaluate(dg);
float amt = amount->Evaluate(dg);
return ( 1.f - amt) * t1 + amt * t2;
}
11.3.4 双线性插值
template
public:
private:
};
BilerpTexture类使用4个常量进行双线性插值。这些值定义在(0,0), (1,0), (0,1), (1,1)的(s,t)参数空间。在给定(s,t)位置的值可以通过对它们的插值来得到。
BilerpTexture(TextureMappi ng2D *m,
const T &t00, const T &t01,
const T &t10, const T &t11){
mapping = m;
vOO = t00;
v01 = t01
v10 = t10;
v11 = t11;
};
TextureMapping2D *mapping;
T v00,v01,v10,v11;
在位置(s,t)的双线性插值可以用3个线性插值来完成。例如,我们先用s在(0,1)和(1,0)之间的值进行插值,结果存放在暂时变量tmp1。然后对(0,1),(1,1)的值进行插值,结果存放在暂时变量tmp2。最后,对使用t对tmp1,tmp2进行插值得到最后结果。其过程如下:
tmp1 = (1 - s)v00 + sv10
tmp2 = (1 - s)v01 + sv11
result = (1 — t)tmp1 + t tmp2
我们可以不需要暂时变量,只需做些代数上的整理,就可以得到同样的结果:
result = (1-s)(1-t)v00 + (1-s)t v01 + s(1-t)v10 + stv11
T Evaluate(const DifferentialGeometry &dg) const {
float s, t , dsdx, dtdx, dsdy, dtdy;
mapping->Map(dg, &s, &t, &dsdx, &dtdx, &dsdy, &dtdy);
return (1-s) * (1-t) * v00 +
(1-s) * t * v01 +
s * (1-t) * v10 +
s * t * v11;
};
11.4 图像纹理
ImageTexture类存放了一个纹理函数的点采样的2D数组。该类使用这些采样值来重构一个可以在任意(s,t)位置上求值的连续的图像函数。我们称这些采样值为纹素(texel),因为它们跟图像中的像素很相似,只不过是用在纹理的语义环境里。在计算机图形学中,图像纹理是应用最广泛的纹理类型;诸如数字图像、艺术品的扫描、图像编辑软件所生成的图像,还有渲染器生成的图像,等等,都是这种纹理类型的极为有用的数据来源。纹理贴图(texture map)这个词常被用于这种纹理,虽然这个用法没有区分开计算纹理坐标的映射和纹理函数本身的差别。
template
public:
private:
};
调用者要提供纹理贴图的文件和控制反走样滤波过程的参数。我们在11.4.2节里再介绍这些参数。文件中的数据被用来创建一个MIPMap类的实例,MIPMap将这些纹素存放在内存中,并处理重构和反走样滤波的细节。
对于那些Texture::Evaluate()返回Spectrum值的ImageTexture,MIPMap存放Spectrum的图像数据。这是有些浪费的表达方式,因为一个图像贴图可能有几百万个纹素,并不一定需要Specturm的32位浮点系数所能达到的精度。
template
ImageTexture
const string &filename,
bool doTrilinear,
float maxAniso,
ImageWrap wrapMode) {
mapping = m;
mipmap = GetTexture(filename, doTrilinear,
maxAniso, wrapMode);
}
MIPMap
TextureMapping2D *mapping;
11.4.1 Texture缓存
因为图像贴图的内存操作频繁,并且用户有可能对场景中的同一个纹理进行多次引用,所以pbrt使用了一个表来维护已经被载入内存的图像贴图,这样一来,即使它们在一个ImageTexture被多次引用,也只需被载入内存一次。ImageTexture构造器调用static ImageTexture::GetTexture()函数来得到所要的纹理的MIPMap表示。如果图像贴图需要被载入内存,ReadImage()函数处理这个载入过程的底层细节,并返回一个纹素值数组。
template
ImageTexture
bool doTrilinear, float maxAniso, ImageWrap wrap) {
int width, height;
Spectrum *texels = Readlmage(filename, &width, &height);
MIPMap
if (texels) {
}
else {
}
textures[texInfo] = ret;
return ret;
}
TexInfo是一个简单的结构,它存有图像贴图的文件名和滤波参数,在另一个ImageTexture中重用一个MIPMap时,结构中的这些值必须匹配。这里略去了它的定义。
static map
TexInfo texInfo(filename, doTrilinear, maxAniso, wrap);
if (textures.find(texInfo)!= textures.end())
return textures[texInfo];
因为图像载入程序返回纹理的Spectrum值数组,所以有必要将这些值转换成MIPMap所使用的特定的类型T(如果T不是Specturm)。工具函数ImageTexture::convert()处理其中的转换。如果MIPMap是以Specturm类型存储的,这个工作就白费了,但这样做是为了提供一定的灵活性。
for (int i = 0; i < width*height; ++i)
convert(texels, &convertedTexels);
ret = new MIPMap
maxAniso, wrap);
delete[] texels;
delete[] convertedTexels;
纹素的类型转换是通过C++的函数重载来实现的。对于每个要转换的类型,必须提供一个ImageTexture::convert()函数。在前面的对纹素的循环里,C++的函数重载机制可以根据目标类型来选择适当的ImageTexture::convert()的实例。不幸地是,我们无法从函数返回被转换的值,因为C++不支持关于返回类型的重载。
static void convert(const Spectrum &from, Spectrum *to){
*to = from;
}
static void convert(const Spectrum &from, float *to) {
*to = from.y();
}
如果无法找到纹理文件或者文件不可读,则返回只有一个值的纹理贴图,使得渲染器可以继续运行而不至于中断。ReadImage()函数在这种情况下产生警告信息。
T *oneVal = new T[1];
oneVal[0] = 1.;
ret = new MIPMap
delete[] oneVal;
ImageTexture::Evaluate()例程还是很简单的:它计算纹理坐标,并利用计算结果来做MIPMap查找,实际上是做图像反走样的滤波工作。
template
T ImageTexture
float s, t , dsdx, dtdx, dsdy, dtdy;
mapping->Map(dg, &s, &t, &dsdx, &dtdx, &dsdy, &dtdy);
return mipmap->Lookup(s, t , dsdx, dtdx, dsdy, dtdy);
}
11.4.2 MIP贴图
如果图像函数有高于纹理采样速率所能表达的频率细节,在最终的图像结果里肯定会出现走样。任何高于Nyquist极限的频率都应该在函数求值前通过前置滤波被去除掉。下面的图显示了我们必须要面对的基本问题:一个图像纹理的纹素是某种图像函数在给定频率下的采样。给定了要进行图像贴图的位置以及纹理空间的采样速率(图中用相邻的实心点表示),我们必须滤波掉纹理贴图中大量的纹素(用空心点表示)的贡献值。用于查找的滤波区域是由(s,t)中心点和相应的偏移量给出的,这些偏移量是对相邻图像采样点的纹理坐标位置的估算值。由于这些偏移量是按照纹理采样速率估算的,所以我们必须去除掉任何高于两倍于相邻采样距离的频率,这样才能满足Nyquist的限定。
纹理采样和重构过程跟第7章中讨论的图像采样过程相比有几个关键的不同之处。利用这些关键的不同点,就可以进行更有效率和计算更省时的纹理反走样。例如,我们可以很容易地取得采样值--这只是数组查找操作而已(而图像采样是要对一条光线进行追踪)。还有,纹理图像函数是由一个采样集合来唯一确定的,其最高频率是显而易见的,并且没有函数在采样之间的不确定性。所有的这些特点能够让我们在采样之前就可以去除掉高频细节,从而达到了反走样的目的。
然而,纹理采样速率在每个像素上都有变化 -- 即是空间变化的。采样速率是由场景的几何形状和朝向、纹理坐标映射函数、相机投影类型和图像采样速率来决定的。由于采样速率不固定,纹理滤波算法就需要有效地对纹理采样的任意区域进行滤波。
MIPMap类实现了两个函数,可以有效地对有空间变化滤波宽度的区域进行纹理滤波。第一个方法是三线性插值,速度很快而又容易实现,在纹理滤波的硬件实现中有广泛的应用。第二个是椭圆加权平均法,速度要慢一些,且更复杂一些,但效果极佳。
为了限制要被存取的纹素个数,这两种滤波方法都用了图像金字塔(image pyramid),即一个对原图像进行前置滤波所产生的一系列的更低分辨率的图像,来加速滤波操作。原图像纹素在金字塔的底端,每层的图像的分辨率是下一层的一半,在最高层是只有一个纹素的图像,它代表了整个原图像的平均值。所以这些图像最多只需多占用1/3的内存,可以用来快速地查找滤波值。金字塔背后的基本思想是,如果需要对大面积的纹素进行滤波,就可以使用金字塔中较高层的图像对同一个区域进行滤波,从而只需使用少得多的纹素。
MIPMap是一个模板类,其参数化类型是图像纹素的数据类型。pbrt创建了Spectrum和float类型的两种MIPMap;例如,float MIP图像可以用来表示测向光源(goniometric light source)的强度的方向分布。MIPMap的实现只需类型T支持几个基本的操作,其中包括加法运算和数乘运算。
typedef enum {
TEXTURE_REPEAT,
TEXTURE_BLACK,
TEXTURE_CLAMP
} ImageWrap;
template
public:
private:
};
在构造器里,MIPMap拷贝调用者所提供的图像数据,如果有必要的话,还要改变图像的大小来保证其分辨率在每个方向上是2的某次方,并且初始化一个用于椭圆加权平均滤波的查找表。另外,还要按照wrapmode的规定,对于处于合法范围外的纹理坐标进行处理。
template
MIPMap
float maxAniso, ImageWrap wm) {
doTrilinear = doTri;
maxAnisotropy = maxAniso;
wrapMode = wm;
T *resampledImage = NULL;
if (!IsPower0f2(sres) || !IsPower0f2(tres)) {
}
if (resampledlmage) delete[] resampledlmage;
}
bool doTrilinear;
float maxAnisotropy;
ImageWrap wrapMode;
如果原图像在每个方向上的分辨率正好是2的某次方,图像金字塔的实现就会容易得多;因为这可以使每层的纹素个数和金字塔层数有直接了当的关系。如果用户提供的图像的分辨率不是如此,MIPMap构造器在构造金字塔之前,就需要改变图像尺寸,将其扩大为下一个2的某次方。
以这种方式扩大图像尺寸需要使用更多的采样和重构理论:我们已经有一个按照某个采样速率进行采样的图像函数,现在我们需要从原采样重构一个连续图像函数,然后在一组新的采样位置上再采样(resampling)。因为这意味着采样速率的增大,但我们不必担心走样问题,因为这一步是对高频成分的欠采样(undersampling);我们可以直接进行重构,并利用新函数再采样。
MIPMap为此使用了一个可分离的重构滤波器;回忆一下第7.6节,可分离的滤波器可以写成两个一维滤波器的乘积: f(x,y) = f(x) f(y)。使用可分离的滤波器的一个优点是,如果我们使用一个滤波器对图像进行从分辨率(s,t)到(s',t')的再采样,那么我们可以用两个一维的再采样步骤来实现再采样过程,第一个步骤是在s方向上再采样,创建一个分辨率为(s',t)的图像,然后对其再采样,创建出分辨率为(s',t')的图像。这种通过两个一维的步骤进行再采样的方法可以简化实现,并且最终图像中的纹素所需存取的纹素个数跟滤波器宽度呈线性函数(而不是二次函数)关系。
int sPow2 = RoundUpPow2(sres), tPow2 = RoundUpPow2(tres);
sres = sPow2;
tres = tPow2;
对原图像函数进行重构再在新纹素位置上在采样,这在数学上等价于将重构滤波器核(filter kernel)定位在新纹素位置上并对原图像中的附近的纹素进行加权平均。所以,每个新纹素是原图像的一小组纹素的加权平均。
MIPMap::resampleWeights()函数确定有那些原纹素对新纹素有贡献值,并确定它们相应的贡献权值。它返回一个ReSampleWeight结构数组,其中存放了一行或者一列纹素的相关结果。因为这个结果对于图像所有的行(在s方向上再采样时)或者列(在t方向上再采样时)都相同,所以只需为s,t两个方向计算一次,所得结果可以重复利用。有了这些权值以后,先将图像在s方向上扩大,将分辨率为(sres,tres)的原图像转换为分辨率为(sPow2,tres)的图像,并存放在resampledImage。在这里的实现中,为resampledImage申请的内存空间可以容纳大小为(sPow2,tPow2)的图像,从而避免了两次很大的内存分配过程。
ResampleWeight *sWeights = resampleWeights(sres, sPow2);
resampledlmage = new T[sPow2 * tPow2];
delete[] sWeights;
对于这里所使用的滤波器而言,在图像放大之后,只有不超过4个的原纹素对新纹素有贡献值,所以ResampleWeight只需存放4个权值。因为四个纹素是连在一起的,我们只需存放第一个纹素的偏移位置。
struct ResampleWeight {
int firstTexel;
float weight[4];
};
ResampleWeight *resampleWeights(int oldres, int newres) {
Assert(newres >= oldres);
ResampleWeight *wt = new ResampleWeight [newres];
float filter-width = 2.f;
for (int i = 0; i < newres; ++i) {
}
return wt;
}
我们在第7章里介绍了像素的离散坐标和连续坐标的区别,这里的纹素坐标也有类似的问题。我们将使用在7.1.7节中的约定。对于每个新纹素,该函数先根据旧的纹素坐标计算其连续坐标。该值被存放到center里,因为它是新纹素的重构滤波器的中心。下一步是要计算对新纹素有贡献值的第一个纹素的偏移位置。这个计算有点复杂---我们将中心坐标减去滤波器宽度来找滤波器范围的起始位置,还需要加上0.5来得到连续坐标,然后再取floor值得到离散坐标。
从这个有贡献值的纹素开始,该函数对4个纹素进行循环,计算每个纹素距离滤波器中心的偏置值和相应的滤波权值。这里计算权值的重构滤波函数是Lanczos(),即LanczosSincFilter::Sinc1D()函数。
float center = (i + .5f) * oldres / newres;
wt.firstTexel = Floor2Int((center - filterwidth) + 0.5f);
for (int j = 0; j < 4; ++j) {
float pos = wt.firstTexel + j + .5f;
wt.weight[j] = Lanczos((pos - center) / filterwdith);
}
滤波函数所得到的4个滤波权值的和不一定是1。所以,为了保证在采样的图像不会比原图像更亮或更暗,有必要对这些权值归一化。
float invSumWts = 1.f / (wt.weight[0] + wt.weight[1] +
wt.weight[2] + wt.weight[3]);
for (int j = 0; j < 4; ++j)
wt.weight[ j ] *= invSumWts;
COREDLL float Lanczos(float, float tau=2);
有了这些权值后,就很容易地使用它们计算图像放大后的纹素了。对于原图像的第tres行水平扫描线做一遍处理,即对在s方向上放大后的图像的sPow2个纹素使用这些权值来得到它们的值。
for (int t = 0; t < tres; ++t) {
for (int s = 0; s < sPow2; ++s) {
}
}
MIPMap构造器中的参数ImageWrap指定了对出界的纹素的处理方式。它要么进行取模(modulus)运算或截取(clamp)运算将这些值重新映射到有效值范围内,要么用一个黑色纹素值来代替。
resampledImage[t*sPow2+s] = 0.;
for (int j = 0; j < 4; ++j) {
int origS = sWeights[s ] . firstTexel + j ;
if (wrapMode == TEXTURE_REPEAT)
origS = Mod(origS, sres);
else if (wrapMode == TEXTURE_CLAMP)
origS = Clamp(origS, 0, sres-1);
if (origS >= 0 && origS < sres)
resampledImage[t*sPow2+s] += sWeights[s ] . weight[j] *
img[t*sres + origS];
}
在t方向上的再采样过程几乎跟s方向上的相同,这里不再列出了。一旦我们有了分辨率为2的某次方的图像,就可以对每层的MIP贴图进行初始化了,从底层开始,每层的图像可以通过对前一层的图像进行滤波来得到。
因为图像贴图用了很大的内存,并且为了计算一个滤波值所做的图像纹理查找需要大约读取8-20个纹素,所以很值得对纹理在内存的布局做认真的考虑,因为降低在读取纹理贴图时的缓存未中次数(Cache misses)可以显著地提高渲染器的性能。由于本节所介绍的两个纹理滤波方法对图像贴图中的矩形区域中的纹素进行读取,MIPMap使用BlockedArray模板来存放纹素的2D数组值,而不是使用标准的C++数组(见A.2.5对BlockedArray的介绍)。
nLevels = 1 + Log2Int(float(max(sres, tres)));
pyramid = new BlockedArray
for (int i = 1; i < nLevels; ++i) {
}
BlockedArray
int nLevels;
MIP贴图的最底层存放了原始数据(如果分辨率不是2的幂值,存放的是再采样后的数据),它是由BlockedArray缺省构造器来初始化的:
pyramid[0] = new BlockedArray
在介绍其它层的初始化过程之前,我们先定义一个要用到的纹素读取函数。MIPMap::texel()返回给定离散坐标所对应的纹素的引用。如前所述,如果传入的是超出范围的纹素坐标,该方法要么使用取模方法返回重复值,要么对坐标进行截取,使其使用边界上的像素,要么返回黑色的纹素。
template
const T &MIPMap
const BlockedArray
return l(s, t );
}
switch (wrapMode) {
case TEXTURE_REPEAT:
s = Mod(s, l.uSize());
t = Mod(t, l.vSize());
break;
case TEXTURE_CLAMP:
s = Clamp(s, 0, l.uSize() - 1);
t = Clamp(t, 0, l.vSize() - 1);
break;
case TEXTURE_BLACK: {
static const T black = O.f;
if (s < 0 || s >= l.uSize() || t < 0 || t >= l.vSize())
return black;
break;
}
}
对于不是正方形的图像,为了建立图像金字塔的上层结构,其中一个方向的分辨率必须被截取,然后对两个分辨率中较大的一个进行欠采样。处理方法如下:
int sRes = max(1, pyramid[i-1]->uSize()/2);
int tRes = max(1, pyramid[i-1]->vSize()/2);
pyramid = new BlockedArray
MIPMap使用了一个简单的盒滤波器对4个纹素取平均值。Lanczos滤波器的效果要好一些,留作练习。
for (int t = 0; t < tRes; ++t)
for (int s = 0; s < sRes; ++s)
(*pyramid)(s, t) = .25f * (
texel(i-1, 2*s, 2*t) +
texel(i-1, 2*s+1, 2*t) +
texel(i-1, 2*s, 2*t+1) +
texel(i-1, 2*s+1, 2*t));
11.4.3 各向同性三角形滤波器
两个MIPMap::Lookup()中的第一个方法使用一个三角形滤波器来去掉纹理采样的高频内容。虽然这个滤波器函数所得到的结果质量不高,但却可以高效地实现。除了求值点的(s,t)坐标外,调用者还要传入一个用于查找的滤波宽度,即滤波器的纹理区域范围。这个函数在纹理空间中的一个正方形区域进行滤波,所以要比较保守地选择这个宽度,以避免在s,t两个方向上的走样。像这样不支持非正方形或轴对齐区域的滤波器被称为各向同性(isotropic)滤波器。各向同性滤波算法的主要缺点是,当以倾斜的角度看纹理时会感觉很模糊,因为沿一个轴的采样速率不同于另一个轴的采样速率。
由于对很宽的滤波宽度而言要对大量的纹素进行滤波,这是很没有效率的,所以该函数在图像金字塔中选择一个层,使得滤波区域在该层覆盖4个纹素。如图:
template
T MIPMap
}
由于金字塔各层的分辨率是2的某次幂,第l层的分辨率是2nLevels-1-l,因此为了求得纹理间距为w的层数,需要解下列方程:
1 / w = 2nLevels-1-l
float level = nLevels - 1 + Log2(max(width, 1e-8f));
如上图所示,对采样点周围四个纹素进行滤波,要么是针对过大的区域,要么是针对过小的区域(除非所选择的滤波宽度经过认真挑选)。这里的实现将对两个层都进行滤波并混合,这有助于隐藏在最终图像中附近像素的MIP层的过渡。这样对两个层的四个纹素进行三角形滤波的结果不会跟对原图像纹素进行滤波的结果完全一致,但在实际应用中其差别并不显著,而且为了该方法的高效性还是值得这样做的。当然,如果对纹理质量的要求很高,就应该使用下一节介绍的椭圆加权平均滤波方法。
if (level < 0)
return triangle(0, s, t );
else if (level >= nLevels - 1)
return texel(nLevels-1, 0, 0);
else {
int iLevel = Floor2Int(level);
float delta = level - iLevel;
return (1.f-delta) * triangle(iLevel, s, t) +
delta * triangle(iLevel+1, s, t );
}
给定了[0,1]x[0,1]的浮点数纹理坐标,MIPMap::triangle()使用一个三角形滤波器对采样点周围的4个纹素进行插值。如图:
该函数首先根据给定MIP层的纹理分辨率对这些坐标进行比例变换,将它们转换为连续的纹理坐标。因为它们是连续的坐标值,而纹理在图像贴图中是用离散纹理坐标来定义的,所以将其转换为通用的表达形式非常重要。这里,我们将用离散坐标做所有的工作,将连续纹理坐标映射到离散空间。
例如,考虑连续纹理坐标为2.4的一维情况:该坐标在离散纹理坐标2(跟连续坐标2.5对应)下方的距离为0.1的地方,而在离散坐标1(跟连续坐标1.5对应)上方距离为0.9之处。如果我们将2.4减去0.5,得1.9,就可以正确地计算它跟离散坐标1和2的距离。
计算出给定坐标到左下方的纹理的距离ds和dt之后,MIPMap::triangle()就可以确定四个纹素的权值,并计算出滤波值。三角滤波其公式为:
f(x, y) = (1 - |x|) (1 - |y|)
template
T MIPMap
level = Clamp(level, 0, nLevels-1);
s = s * pyramid[level]->uSize() - 0.5f;
t = t * pyramid[level]->vSize() - 0.5f;
int s0 = Floor2Int(s), t0 = Floor2Int(t);
float ds = s - s0, dt = t - t0;
return (1.f-ds)*(l.f-dt) * texel(level, s0, t0) +
(l.f-ds)*dt * texel(level, s0, t0+1) +
ds*(l.f-dt) * texel(level, s0+1, t0) +
ds*dt * texel(level,s0+1, t0+1);
};
11.4.4 椭圆加权平均
椭圆加权平均(EWA)算法根据纹理坐标的微分来定椭圆的两个轴,并用高斯滤波函数对纹理进行滤波。如图。
该算法被视为最好的纹理滤波算法之一,是根据采样理论的基本原理而精心推导出来的。跟前面所讲的三角形滤波器不同的是,它可以对任何朝向的纹理区域进行滤波,并且容许在不同的方向上可以有不同的滤波范围。这类滤波器被称为各向异性的(anisotropic)。这种能力可以极大地提高算法结果的质量,因为它可以对两个图像轴上不同的采样速率进行适当的调整。
我们这里不列出该滤波器的整个推导过程了,但我们需要指出的是它的一个与众不同的地方,即它可以作为一种一体化的再采样滤波器:它可以同时计算出高斯滤波纹理函数跟一个图像空间中的高斯重构滤波器的卷积。这跟许多其它的纹理滤波方法相反:它们要么忽略了图像滤波器的作用,要么将其等价地视为一个盒滤波器。即使不使用高斯滤波对图像采样进行滤波,使用其它的有空间变化的图像滤波器也可以提高结果的质量(假定该滤波器跟高斯滤波器有相似的形状,例如Mitchel和带窗口的sinc滤波器)。
如果MIPMap构造器的参数指定了要使用三线性插值滤波,该函数就要计算出一个保守的各向同性的滤波器,并将请求传给三线性查找例程:
template
T MIPMap
float dsl, float dt1 ) const {
if (doTrilinear)
return Lookup(s, t,
2.f * max(max(fabsf(ds0), fabsf(dt0)),
max(fabsf(ds1), fabsf(dt1))));
}
我们用屏幕空间中纹理坐标的偏导数来定义椭圆的轴。查找函数首先确定哪一个轴是主轴(major axis,较长的一个),哪一个轴是次轴(minor axis,较短的一个),如果需要的话,要将它们对调,使得(ds0, dt0)成为主轴。次轴的长度用来选择MIP map的层。
if (ds0*ds0 + dt0*dt0 < ds1*ds1 + dt1*dt1) {
swap(ds0, ds1);
swap(dt0, dt1);
}
float majorLength = sqrtf(ds0*ds0 + dt0*dt0);
float minorLength = sqrtf(ds1*ds1 + dt1*dt0);
下一步是求椭圆的离心率(eccentricity),即主轴长度跟次轴长度之比(注:这跟教科书定义不同,原定义为焦距和主轴长度之比)。大的离心率意味着椭圆很长很细。由于该函数根据次轴长度来选择MIP贴图的层,所以大离心率的椭圆要滤波很多的纹素。为了避免这种开销,我们可以加大次轴长度来限制离心率。这样做可能加大模糊度,但是在实际效果上影响并不显著。
if (minorLength * maxAnisotropy < majorLength) {
float scale = majorLength / (minorLength * maxAnisotropy);
dsl *= scale;
dtl *= scale;
minorLength *= scale;
}
跟三角形滤波器一样,EWA滤波器在进行纹理查找时,利用图像金字塔来减少要滤波的纹素个数,它根据椭圆次轴的长度选择MIP贴图的层。由于我们有如上所述的离心率的截取(clamping)措施,要过滤的纹素个数也被界定了。给定了椭圆次轴的长度,选择适当的金字塔层也跟三角形滤波器所用的方法相同。类似地,这里的实现也是对两层图像进行滤波并混合,这样可以减少从一层到另一层过渡所产生的人为缺陷。
float lod = max(0.f, nLevels - 1.f + Log2(minorLength));
int ilod = Floor2Int(lod);
float d = lod - ilod;
return (1.f - d) * EWA(s, t , ds0, dt0, ds1, dt1 , ilod) +
d * EWA(s, t , ds0, dt0, ds1, dt1, ilod+1);
MIPMap::EWA()函数在指定层上实施滤波操作。
template
T MIPMap
float ds1, float dt1, int level) const {
if (level >= nLevels) return texel(nLevels-1, 0, 0);
}
该函数先将在[0,1]x[0,1]的纹理坐标和微分转换到所选择的MIP贴图的层中。跟MIP::triangle()一样:将连续坐标减去0.5,将采样点调整为离散纹理坐标。
(Convert EWA coordinates to appropriate scale for level)
s = s * pyramid[level]->usize() - 0.5f;
t = t * pyramid[level]->vsize() - 0.5f;
ds0 *= pyramid[level]->usize();
dt0 *= pyramid[level]->vsize();
ds1 *= pyramid[level]->usize();
dt1 *= pyramid[level]->vsize();
下一步计算中心在原点,轴分别为(ds0, dt0),(ds1,dt1)的椭圆隐式方程的系数。 将椭圆中心定位在原点是为了计算方便,我们可以在求值以后再将结果矫正过来。 所有在这样的椭圆之内的(s,t)点满足下式:
e(s, t) = As2 + Bst + ct2 < F
将所有系数除F, 得到下列够有效率的公式:
e(s, t) = (A/F)s2 + (B/F)st + (C/F)t2 = A's2 + B'st + C't2 < 1
我们不在这里推导求系数的等式了。
float A = dt0*dt0 + dt1*dt1 + 1;
float B = -2.f * (ds0*dt0 + ds1*dt1);
float C = ds0*ds0 + ds1*ds1 + 1;
float invF = 1.f / (A*C - B*B*0.25f);
A *= invF;
B *= invF;
C *= invF;
下一步是求得离散纹理坐标系中有可能在椭圆之内的纹素的包围盒。EWA对包围盒内的候选纹素进行循环,对那些确实在椭圆之内的纹素的贡献值进行滤波。我们通过确定椭圆在s和t方向上的最大、最小值来得到包围盒。我们求得e(s,t)的偏导数,得到其在s=0和t=0的解,再加上到椭圆中心的偏移量即可得到这些极值。这里略去了推导。
float det = -B*B + 4.f*A*C;
float invDet = 1.f / det;
float uSqrt = sqrtf(det * C), vSqrt = sqrtf(A * det);
int S0 = Ceil2Int (s - 2.f * invDet * uSqrt);
int s1 = Floor2Int(s + 2.f * invDet * uSqrt);
int t0 = Ceil2Int (t - 2.f * invDet * vSqrt);
int t1 = Floor2Int(t + 2.f * invDet * vSqrt);
有了包围盒之后,EWA算法对其中的纹素进行循环,用一个平移变换将每个纹素变换到查找点(s,t)为原点的坐标系。然后对椭圆方程进行求值来判断纹素是否在椭圆之内。e(s,t)值等于“纹素到中心的距离”和“纹素与中心连线在椭圆边上的交点到中心的距离”的比率平方。这个值有个好处,即我们可以用它索引一个经过预计算的高斯滤波函数值。如图。
如果纹素在椭圆之内,就用位于椭圆中心的高斯滤波函数计算其权值。最后的滤波值等于所有在椭圆内的纹素(s', t')的加权平均,如下(f为高斯滤波函数):
T num(0.);
float den = 0;
for (int it = t0; it <= t1; ++it) {
float tt = it - t;
for (int is = s0; is <= si ; ++is) {
float ss = is - s;
}
}
return num / den;
float r2 = A*ss*ss + B*ss*tt + C*tt*tt;
if (r2 < 1.) {
float weight = weightLut[min(Float2Int(r2 * WEIGHT_LUT_SIZE),
WEIGHT_LUT_SIZE-1)];
num += texel(level, is , it ) * weight;
den += weight;
}
在构造MIPMap时,我们预先计算好一个权值查找表,使用时要如上所述的比值平方来索引。
#define WEIGHT_LUT_SIZE 128
static float *weightLut;
if (!weightLut) {
weightLut = (float *)AllocAligned(WEIGHT_LUT_SIZE * sizeof(float));
for (int i = 0; i < WEIGHT_LUT_SIZE; ++i) {
float alpha = 2;
float r2 = float(i) / float(WEIGHT_LUT_SIZE - 1);
woightLut = expf(-alpha * r2);
}
}