深入探索透视纹理映射(下)

深入探索透视纹理映射(下)


我的建议:建议学习一下图形学的知识

计算机图形学
赵明、李振波
中国农业大学  公开课   第二讲 光栅图形学算法
http://www.icourse163.org/learn/cau-45006#/learn/content




-潘宏 http://blog.csdn.net/popy007/article/details/5570803

 

在上一篇文章中,我们探讨了学习透视纹理映射所需要的基础知识

我们知道了顶点在通过透视投影变换之后,是如何一步一步通过流水线进入屏幕空间的。

也知道了一个非常简单的三角形扫描线转换算法,以及通过线性插值实现的仿射纹理映射

尽管我们使用的这个流程非常的直接、简洁,还有大量的细节没有添加(片元操作、雾化、颜色累加、混合等等等等),但这些真的就是组成一个固定流水线的简单光栅器的基本步骤了。

但我们目前所提及的光栅化算法完全局限于屏幕空间——我们完全没有考虑进入屏幕空间之前的转换过程,只是在屏幕空间里面对纹理坐标玩弄线性插值。可正如我所说的,仿射纹理映射所基于的假设是不对的,对纹理坐标本身做线性插值也是不对的。那么,错在什么地方呢?我们来分析一下。



仿射纹理映射错在什么地方?

到底错在什么地方呢?我们再来看看我们上一篇的仿射纹理映射算法,我们把其中的一部分伪代码实现出来:

 

double x, y, xleft, xright;

double s, t, sleft, sright, tleft, tright, sstep, tstep;

for(y = y0; y < y1; ++y)

{

       xleft = y和左边的直线方程来求出左边的x

       xright = y和右边的直线方程来求出右边的x

       sleft = (y – y0) * (s1 – s0) / (y1 – y0) + s0;     sleft = 用y对s0,s1插值来求出左边的s

       sright = (y – y0) * (s2 – s0) / (y2 – y0) + s0; sright = 用y对s0,s2插值来求出右边的s

       tleft = (y – y0) * (t1 – t0) / (y1 – y0) + t0;   tleft = 用y对t0,t1插值来求出左边的t

       tright = (y – y0) * (t2 – t0) / (y2 – y0) + t0;   tright = 用y对t0,t2插值来求出右边的t

       sstep = (sright – sleft) / (xright – xleft);

       tstep = (tright – tleft) / ( xright – xleft);

       for(x = xleft, s = sleft, t = tleft; x < xright;

++x, s += sstep, t += tstep)

       {

               帧缓冲像素[x, y] = 纹理[s, t];

       }

}

 

请注意,在上面的算法中,我们计算sleftsright以及tlefttright的时候,是做了关于y的线性插值


这表明在y方向上,纹理坐标st的变化和y的变化是按照线性、均匀的方式处理的。

另外,纹理坐标st扫描线步长ssteptstep的计算,是根据扫描线的长度平均分配纹理变化量,也是按照线性、均匀的方式处理的。

但是问题在于:投影平面上的线性关系,还原到空间中,就不是那么回事了,这还要从透视投影那段说起,请看下图。

这张图是相机空间的一张俯视图。我们把一个多边形通过透视投影的方式变换到了投影平面上,

图中红色的是空间中的多边形,蓝色的是变换到投影平面之后的多边形

现在我们暂时在投影面上插值,而不在视口中,后面我们会把结论推广到视口中,而上面那个算法放在投影平面上同样适用。

可以看到,在投影平面上的蓝色线段被表示成若干个相等的单位步长线段相当于我们在上面的算法中递增扫描线位置的步骤——“++x

而同时也可以看到,投影面上单位步长的线段所对应的投影之前的红色线段长度却不是相等的,从左到右所对应的长度依次递增。而实际上,我们的纹理坐标是定义在红色的多边形上的因此纹理坐标的增量应该是和红色线段的步长对应


但我们的线性插值却把纹理坐标增量根据蓝色线段的步长平均分配了,就是

 

sstep = (sright – sleft) / (xright – xleft);

tstep = (tright – tleft) / ( xright – xleft);

 

这两步。此外在y方向上的插值slefttleftsrighttright全部都是这样处理的——全部都是错误的!则我们得出的结论是:投影平面上的xy和纹理坐标st不是线性关系。即

说了这么半天,我们还没看过仿射纹理映射和透视纹理映射到底差在哪里。下面这张图展示了使用仿射纹理映射导致的错误渲染

左边是让多边形投影平面平行时候的渲染,这个时候没有任何问题。

右边两个是让多边形和投影平面倾斜一定角度

           可以看到中间的仿射纹理映射出现了渲染错误——纹理扭曲了——直接对纹理坐标使用线性插值的结果

           右边是使用带透视校正的透视纹理映射的效果,不错吧?

以上我们从几何直观上感性地认识了仿射纹理映射的错误,现在,我们要从理性上认识它的错误——从数学上来推导正确的方式。



透视纹理映射的数学推导

这个题目看起来有点严肃。但是请放松,只要掌握了第一篇提到的线性关系和线性插值的理论,并且理解透视投影变换,你完全能够理解这些推导,并把它应用到自己需要解决的问题当中。

我们先从最原始的透视投影关系开始推导纹理映射,然后再考虑完整的透视投影变换矩阵下的透视纹理映射关系(二者其实是一样的,但我要证明给你看)。还是来看我们在推导透视投影变换的时候用到的关系图

上图是在相机空间的俯视图,

         eye是眼睛的位置,也就是原点。

         npfp分别是近、远裁剪平面,NF分别是z=0到两个裁剪平面的距离。

         pq是一个三角形pqrxy平面上的两个点,p的坐标为(x, y, z),p’ p投影之后的点,坐标为(x’, y’, z’),则有

fig7.GIF.gif管注释:这个是取自《深入探索透视投影变换》的

 

这个结果就是我们在《深入探索透视投影变换》中所说的野蛮的、原始的投影目的(90年代透视投影)。

另外,在相机空间中,三角形pqr是一个平面,因此它内部的每一条边上的xz,以及yz都是线性关系【我的理解x和z 算一个平面,y和z算一个平面,三角形的每一条边上的x和z的值是线性关系,以此类推】,即

这样,把上面投影之后的结果(1)带入这个线性式(2)(为了书写方便,现在开始我只处理x方向计算,y的情况一致),有

则我们通过这个式子推出了投影之后的x’和原始z之间的关系——x’1/z是线性关系y’1/z也是线形关系

现在回忆我们上一篇文章中讲到的线性插值理论,我们可以说:因为x’y’1/z是线形关系,因此我们可以在投影面上通过x’y’1/z进行线性插值

       至此我们可以得到这样的透视纹理映射思路

在投影平面上通过x’y’1/z线性插值计算出1/z通过上面的1)式计算出原始的xy

然后在3D空间中通过xy计算出stxyst都是在3D空间中的三角形上定义的,是线性关系)。

这样就找到了投影面上一个点对应纹理坐标的正确值了。

这个思路没有问题,可以正确的解决透视纹理映射问题了。

         算法修改如下:

 

double x, y, xleft, xright; // 插值xy,左右线段x

double oneoverz_left, oneoverz_right; // 左右线段1/z

double oneoverz_top, oneoverz_bottom; // 上下顶点1/z

double oneoverz, oneoverz_step;   // 插值1/z以及扫描线1/z步长

double originalx, originaly, originalz; // 空间中的原始xyz

double s, t; // 要求的原始st

光栅化 大概就是根据y 值一点一点推进,如图 黄线部分

for(y = y0; y < y1; ++y)   

{

//在投影平面上通过x’y’1/z线性插值计算出1/z

//x' = -N*B/Z - AN =>   x' = C*1/Z + D  => 1/Z = Ax' + B  x’1/z是线性关系


       xleft = y和左边的直线方程来求出左边的x  【我的理解:因为这里只知道y ,所以用y和x的线性关系求出x。是不是就是上图点q的x】

       xright = y和右边的直线方程来求出右边的x



请注意,在上图的算法中,我们计算sleftsright以及tlefttright的时候,是做了关于y的线性插值。表明在y方向上,纹理坐标st的变化和y的变化是按照线性、均匀的方式处理的。所以是错误的,以至于出现了

上下面的算法唯一不同的就是,下面是 通过x’y’1/z线性插值

左图解释了 oneoverz_left 的计算

光栅化 大概就是根据y 值一点一点推进,如图 黄线部分【黄线应该从p向q递进,因为y从y0开始】,类似下图【蓝色是投影屏幕的多边形】【所谓的左边线段就是 p-q,右边线段就是 p-eye,因为它们拥有不同的Z , q坐在的Z定义为Z1,p所在的z定义为Z2】

在第一循环中,y1-y0 和 y2-y0 是等值的吧????

           oneoverz_top = 1.0 / z0;

           oneoverz_bottom = 1.0 / z1;

           //左边线段的1/z  【这个值不是就是x'】下图可知,求出来的时 left的1/z

通过 y’【是不是就是y-y0】 1/z 线性插值????

               oneoverz_left = (y – y0) * (oneoverz_bottom – oneoverz_top) / (y1 – y0) + oneoverz_top;

           oneoverz_bottom = 1.0 / z2;

          //右边线段的1/z  【这个值不是就是x'】

               oneoverz_right = (y – y0) * (oneoverz_bottom – oneoverz_top) / (y2 – y0) + oneoverz_top;


       oneoverz_step = (oneoverz_right – oneoverz_left) / (xright – xleft);



1/z关于x’y’插值得到1/z’,也就是 oneoverz 的值

       for(x = xleft, oneoverz = oneoverz_left; x < xright;

++x, oneoverz += oneoverz_step)  

//如下图,扫描线 是从oneoverz_left 开始递增,每一步递增oneoverz_step,直到x < xright,可知黄色线是从图中上往下扫描的


       {

//在投影平面上通过x’y’1/z线性插值计算出1/z通过上面的1)式计算出原始的xy

              originalz = 1.0 / oneoverz;

              originalx = -x * originalz / N;

              originaly = -y * originalz / N;     //使用下图的公式

              originalxoriginaly以及originalz在空间中通过线性插值找到相应的st

              帧缓冲像素[x, y] = 纹理[s, t];

       }

}

上面的算法根据x’y’1/z进行线性插值,是完全正确的,因为它们是线性关系。

第一层循环中,通过插值计算出左边线段的1/z右边线段的1/z

然后在第二层循环中计算扫描线上的每一个1/z——oneoverz

            接着把1/z取倒数得到原始z,用上边的(1)式计算出原始xy,此时就得到了扫描线上一点所对应的原始3D

用这个点关于原始的P0P1P2三个点在空间做线性插值(空间中这些量都是线性的)就可以得到当前点的纹理坐标[st]

这就是一个简单、正确的透视纹理映射算法!

看起来还不错,我们已经找到了正确的透视纹理映射方法,但是上面的算法中有个地方似乎写得有点模凌两可:

 

originalxoriginaly以及originalz在空间中通过线性插值找到相应的st

 

这个步骤是正确的,但是有一个问题——计算次数太多了,有些繁琐——我们还需要在空间中再进行几次线性插值才能得到想要的东西。

有没有更简单的方式呢?当然了!

我们注意到,在空间中,xyst都是线性的(因为三角形是平面),所以有关系

把(4)带入(1

把(3代入上式的中间项,得到(常数都进行合并)

我们发现s/zt/zx’y’也是线性关系。

而我们之前知道1/zx’y’是线性关系

则我们得出新的思路

         (1)对1/z关于x’y’插值得到1/z’【这里是不是错了,1/z吧??????,对的,就是下面的oneoverz

         (2)然后对s/zt/z关于x’y’进行插值得到s’/z’t’/z’

         (3)然后用s’/z’t’/z’分别除以1/z’,就得到了插值s’t’。这样就不用空间中的插值步骤了!我们看看这个算法:




double x, y, xleft, xright; // 插值xy,左右线段x

double oneoverz_left, oneoverz_right; // 左右线段1/z

double oneoverz_top, oneoverz_bottom; // 上下顶点1/z

double oneoverz, oneoverz_step;   // 插值1/z以及扫描线步长

double soverz_top, soverz_bottom; // 上下顶点s/z

double toverz_top, toverz_bottom; // 上下顶点t/z

double soverz_left, soverz_right; // 左右线段s/z

double toverz_left, toverz_right; // 左右线段t/z

double soverz, soverz_step; // 插值s/z以及扫描线步长

double toverz, toverz_step; // 插值t/z以及扫描线步长

double s, t; // 要求的原始st

for(y = y0; y < y1; ++y)

{

//思路第一步

       xleft = y和左边的直线方程来求出左边的x

       xright = y和右边的直线方程来求出右边的x

       oneoverz_top = 1.0 / z0;

       oneoverz_bottom = 1.0 / z1;

              oneoverz_left = (y – y0) * (oneoverz_bottom – oneoverz_top) / (y1 – y0) + oneoverz_top;

       oneoverz_bottom = 1.0 / z2;

              oneoverz_right = (y – y0) * (oneoverz_bottom – oneoverz_top) / (y2 – y0) + oneoverz_top;

                        oneoverz_step = (oneoverz_right – oneoverz_left) / (xright – xleft);


//思路第二步


       soverz_top = s0 / z0;

       soverz_bottom = s1 / z1;

              soverz_left = (y – y0) * (soverz_bottom – soverz_top) / (y1 – y0) + soverz_top;

       soverz_bottom = s2 / z2;

              soverz_right = (y – y0) * (soverz_bottom – soverz_top) / (y2 – y0) + soverz_top;

                           soverz_step = (soverz_right – soverz_left) / (xright – xleft);


       toverz_top = t0 / z0;

       toverz_bottom = t1 / z1;

              toverz_left = (y – y0) * (toverz_bottom – toverz_top) / (y1 – y0) + toverz_top;

       toverz_bottom = t2 / z2;

             toverz_right = (y – y0) * (toverz_bottom – toverz_top) / (y2 – y0) + toverz_top;

                            toverz_step = (toverz_right – toverz_left) / (xright – xleft);


s/zt/z关于x’y’进行插值得到s’/z’t’/z’,  soverz 和 toverz 就是

       for(x = xleft,oneoverz = oneoverz_left,

              soverz = soverz_left, toverz= toverz_left,

              x < xright; ++x, oneoverz += oneoverz_step,

              soverz += soverz_step,toverz += toverz_step)

       {

              s = soverz / oneoverz;  //思路的第三步

              t = toverz / oneoverz;

              帧缓冲像素[x, y] = 纹理[s, t];

       }

}

上述算法对1/z以及s/zt/z进行线性插值,得到结果之后就地相除,得到了插值点对应的原始纹理坐标避免了在空间中再次插值,实现了正确的透视纹理映射。

可以看到透视纹理映射实质上使用的仍然是线性插值,但关键点在于找到了  投影前后具有正确线性关系的几个量

此外,可以看到这个算法的性能还有很大的提升空间,我们在后面还会提到这一点。




推广到视口

前面我们推导这个算法的时候使用的是野蛮版本透视投影关系,

但实际我们在流水线中使用的透视投影矩阵是经过了CVV规划的版本,也就是我们在《深入探索透视投影变换》一文中导出的最终矩阵

如果使用这个最终矩阵,会不会对上面的算法有所影响呢?答案是不会。

我在前面说过要证明一下(如果你对这个证明不感兴趣,可以直接跳到下一节)。

我们在投影平面上的这个透视投影算法其实有两个关键点,只要满足了这两个关键点,算法就是正确的。

 

1)最终投影点xy1/z是线性关系

2)最终投影点xys/zt/z是线性关系

 

我们已经证明了投影点

1/zs/zt/z是线性关系(上面的推导)。

我们的最终投影点应该是在CVV中的(如果对此感到迷惑,请参考《深入探索透视投影变换》),

我们要把目前的x’y’变换到CVV[-1, 1]中,得到最终的投影点,这是通过线性插值得到的,也就是

其中Ax’+BAy’+B是最终的投影点。因为x’1/zs/zt/z是线性关系,而Ax’+Bx’是线性关系,则根据线性关系的传递性Ax’+B1/zs/zt/z是线性关系,Ay’+B同理,从而证明了(1)(2)。

此时就证明了:用最终的透视投影变换得到的最终投影点也是满足这个算法的。

至此我们在投影平面和CVV中都证明了这个透视纹理映射算法的正确性

    下一个要证明的就是从CVV通过视口变换,进入到视口中的图元点,是否也可以使用这个算法。

其实稍微想一下就知道,视口变换本身就是一个线性变换(请参考上一篇文章的视口变换一节),

因此对于上面推导出的CVV中的投影点

进行视口变换不过就是对它们再次进行线性插值

根据线性关系的传递性,这两个点和1/zs/z以及t/z也是线性关系。

算法在视口中同样适用。所有证明完毕。

意外知识收获——w缓冲

一些题外话。不知道你想过没有,仿射纹理映射算法不仅可以用来计算st,还可以用来计算z值。由于同样的原因,得到的z值也是不正确的,

但仿射计算效率比较高。

另外,因为z只是用来决定遮挡关系,虽然数值上是错误的,但先后的顺序影响不大,所以大多流水线计算z缓冲时候都用这种仿射方法

而我们在透视纹理映射算法中计算出来的1/z,却获得了数值正确的深度值,使用这种正确的1/z的缓冲叫做w缓冲(也叫OOZ缓冲、One Over Z缓冲、1/z缓冲),

但并不是所有的图形硬件都支持这种缓冲——有些只能靠软件来实现。

关于z缓冲和w缓冲的一些知识和使用经验,Steve Baker的文章Learning to love your z_buffer值得一看。



Perspective Texture Mapping》导读

上面我们通过数学推导,实现了一个正确的透视纹理映射算法。

实际上,实现一个完整的软件光栅器还有很多的事情要做。但至少你已经找到了一把打开这扇门的钥匙——我们在核心层面上已经掌握了透视纹理映射技术——现代光栅器的核心。

另外,如果你真的要实现一个软件光栅器,我给你推荐Chris Hecker的系列文章《Perspective Texture Mapping》。我们这里的很多知识,都是来源于这个系列。

另外,Chris Hecker的好朋友Michael Abrash,有一个系列文章叫做《Ramblings in Realtime》(我管它叫《Quake技术内幕》),里面记载了他和John Carmack一起研制Quake时候关于技术的方方面面。其中就提到了关于Quake的透视纹理映射,也是基于Chris Hecker在文章中所提到的技术

因此,可以说Quake引擎中的透视纹理映射就是使用这样的插值技术实现的。

Perspective Texture Mapping》中使用了很多非常棒的技巧,比如三角形的整体坡度计算,可以不用像我们上面的算法中,每次都重复计算三角形内部的一些增量。

还有像素的填充规则,这个是非常重要的光栅化技巧,没有填充规则,模型的很多部分都会重复绘制或者无法被绘制。

基于误差项的前向微分的DDA迭代方法,避免了浮点数运算等等。

他把一个简单的透视纹理映射光栅器进行了一次又一次的优化、升级,最终写成一个能够实际运用到游戏中的软件渲染器。



下面就是关于这个系列文章的导读,对你理解这个系列应该有所帮助。


第一章Chris Hecker完全用浮点数进行透视纹理映射,然而因为浮点数强制转换成整数速度比较慢,




因此在第二章对光栅化采用了带有误差项的前向微分的DDA方式,同时将所有三角形顶点光栅化初始阶段变成了整数形式,在速度上有所提升。但就是因为这个整数转换,导致三角形的整体梯度计算在纯整数范围产生较大变化量,出现了纹理抖动情况




故在第三篇文章引入了28.4定点数处理三角形梯度计算,从而解决了这个问题。但同时又发现一个新的纹理坐标问题:纹理坐标在插值后是个小数,不是整数。这个透视纹理映射器在光栅化的时候直接把当前像素的纹理坐标截断成了整数,从而使所有纹理坐标都落到了小于或等于它的整数上,但像素不是一个点,而是一个边长为1的方块。从而使得下边这样的情况下

C落入了N-1像素上,而D落在N像素上,但根据位置关系,CD都应该属于N纹理像素上。这就需要把小数纹理坐标转换成整数纹理坐标的约定

两个约定方式

都可以实现CD局限在N纹理像素上

但二者的区别在于,当u正好落在两个纹理像素的边界上时,前者会把坐标右移,而后者会把坐标左移。从而产生了左上舍入和右下舍入两种模式。

如果纹理坐标在[0, 0][TextureWidth, TextureHeight],用这两种方式都可以。

但纹理像素和屏幕像素一样是边长为1的方块,不是点,而同时屏幕像素的范围是[-0.5, -0.5][ScreenWidth-0.5, ScreenHeight-0.5],因此纹理像素坐标应该是[-0.5, -0.5][TextureWidth-0.5, TextureHeight-0.5]。这样就产生了一个问题:如果在纹理坐标左边界u= -0.5使用右下舍入或者在右边界u=TextureWidth-0.5使用左上舍入,则纹理坐标会越界。因此需要根据不同情况采用这两种舍入约定。这一点在文章附带的代码文件GRADIENT.TXT中给出了一个具体的实现方案




第四篇文章中对光栅器进行了性能剖析,发现速度瓶颈主要在于计算扫描线中1/z这个除法上(考虑文章发表在90年代)。因此需要对扫描线算法进行改进。这一点可以从视口x和采样纹理坐标的关系出发,它们的关系是如下一个图形:

可以通过三种办法来实现这个优化:

(1)       固定z的直线方法:找到多边形的一个特殊方向,在这个方向上,所有投影后的片元的z值都相等。这样就在一个非轴对齐的扫描线上进行纹理坐标线性插值(DOOM使用的就是这个方法)。

(2)       二次曲线去逼近上述图形。

(3)       分段仿射纹理映射的方法。对每一行扫描线,取固定长度线段用仿射方式作近似,可以达到一个非常逼近上述图形的曲线。

第四篇文章最终选择了用第三种方法来优化程序。

第五篇文章使用了终极武器——汇编语言的方式作了最终优化,把这个软件光栅器优化到了一个能够在实际项目中使用的程度(考虑90年代个人计算机硬件能力)。

以上就是对这个系列文章的导读。



文章可以在下面的连接中找到。

最后,如果你在研究这个领域时有什么问题或者想法,欢迎与我交流。下次见!

http://www.chrishecker.com/Miscellaneous_Technical_Articles



  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
提供的源码资源涵盖了Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 适合毕业设计、课程设计作业。这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。 所有源码均经过严格测试,可以直接运行,可以放心下载使用。有任何使用问题欢迎随时与博主沟通,第一时间进行解答!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值