(转)3D场景中的圆形天空顶
引言
如果我们希望在场景里添加一个天空,我们可以仅仅只把背景清除为淡蓝色,不过我相信你会认为那样做太普通了。一般来说,那些让你从窗户或洞孔中向外张望的室内游戏使用的是天空盒。只要使用好的纹理,天空盒将提供让人信服的画面;由于纹理伸展在一个巨大的多边形上,低质量的纹理将很容易被注意到。在户外场景中使用天空盒会引发一些问题,例如使用雾,某些雾的设置可能会引发一些问题。如果雾被设置在观察者的旁边,天空盒将减淡甚至消失。另一个可能发生的更烦人的问题是雾会聚积在天空盒的顶点处,从而将天空盒的多边形暴露无遗。下面的图1显示了在天空盒下使用雾所引发的两个问题.小心地调整雾化参数或者细分天空盒的每一个面可以减少这些问题,但这样会大大影响性能。
图1. 天空盒的边界被暴露 天空盒几乎无法看见
在户外场景中使用圆形天空顶取代的天空盒的优点是:由于以更多的顶点构成天空顶,雾将被更平均地渲染。你也可以通过实时改变某些个别顶点的颜色创建一些很酷的效果,例如在一天不同时间里模拟真实的日照。我们也可以使用多纹理,添加个别缓缓移动过天空的云的纹理。
从哪里开始?
天空顶可以描述为一个半球,图2展示了一个线框模型,我们的天空顶看起来就像那个样子。
图2.天空盒的侧景
由此,我们应该以寻找在3D中渲染一个半球的方法而开始,首先,请看创建一个半球的数学方法,方程:
x2 + y2 + z2 = r2
描述了一个球心在笛卡尔坐标系原点,半径为r的球体。我们可以把它写成下面的方程:
f(p) = x2 + y2 + z2 - r2 = 0
在上面的方程中,p是圆上一点。使用上面的方程处理球体可能有些棘手,因此我们转向使用球面坐标系,在球面坐标方式下,球体上的一点将由下面的方程决定:
p = (px, py, pz) = f(ρ, φ) = (fx(ρ, φ), fy(ρ, φ), fz(ρ, φ))
在这个方程中,ρ(phi [fai])称为纬度,而φ(theta)称为经度。那么,球面坐标系下球体的方程如下:
fx(ρ, φ) = r sin(ρ) cos(φ)
fy(ρ, φ) = r sin(ρ) sin(φ)
fz(ρ, φ) = r cos(ρ)
其中,r为球体半径,我们将使用这个方程,因为只要给出一个点的纬度和经度,就可以求出该点的x, y, z 值。对于一个球来说,ρ的取值范围是-90°到90°,φ的取值范围是0°到360°,即:
-π/2 <= ρ <=π/2
0 <= φ <= 2π
上面是一个完整的球体的取值,我们并不需要,但有必要了解。我们会继续渲染出一个完整的球体,然后,我们会看到怎样将它缩减到我们所需要的形状,不过你或许已经知道了我们将要怎样做。
现在我们已经知道了怎样获取球体上一个点的坐标,我们再制定一点算法规则,使我们有足够的点来渲染球体。首先,我们需要一点额外信息,球面上有无限多个点,我们当然不需要所有的这些点来渲染一个球体,实际上,只要有两百多个点,我们就能渲染出一个优美的球体(已被公认,可能更少,不过就不会好看了)。φ的取值范围是0°到360°,有无穷多个取值,所以,我们要做的就是取一个值,称为Dφ (dtheta),这个值包括了一定的数量,如此我们就可以从0 到2π间反复取值,沿XZ平面得到 2π/ Dφ个点。比方说对于取值为30°的一个Dφ,我们将得到360° / 30° = 12个点,Dφ取得越小,我们就能得到越多的点(当你实际渲染一个球体时要小心,如果你选择的Dφ太小,你的球体将由巨大数量的顶点所组成,从而影响程序性能)。
算法规则的伪码如下:
for (ρ从 -π/2 到 π/2, ρ += dρ) { for (φ 从 0 到2π, φ += Dφ) { px = r * sin(ρ) * cos(φ) py = r * sin(ρ) * sin(φ) pz = r * cos(ρ) } }
注意:使用三角函数时一定要小心,在我们的例子中是sin和cos,它们使用的是弧度,而在我的算法中,ρ和φ使用的是角度,这样我们能够使用一个for循环而没有由于浮点错误的问题。在代码中,你将看到,在调用sin和cos函数时我对这些值进行了转换。
现在我们所得到的是一些在3D空间里分散的点,在你的游戏中,它们能产生一个很酷的效果,但是对我们的天空顶用处不大。我们希望的是"连接这些点",建立一系列三角形,从而我们能渲染真实的球体(和天空顶)。有很多种不同的算法可以完成这一点,我们要用的只是已有算法的一个扩展。这个算法很容易实现,并能产生好的效果。如果你需要更好的或不同的效果,你可以选择一个不同的算法。球体生成算法已超过本文范围,网上有很多关于这方面的信息。我们要使用的算法将产生一个三角形带。生成一个三角形带需要四个顶点,前面三个顶点构成一个三角形,而第四个顶点描述了第二个三角形,这个三角形的另外两个顶点就是前面三角形的第二和第三个顶点。
构成我们三角形的顶点是:
ρ, φ
ρ + Dρ, φ
ρ, φ + D φ
ρ + Dρ, φ + D φ
正如你所见,这四个点构成了一个我们所需要的三角形带,用来正确地渲染我们的球体或天空顶。那么,我们现在开始,首先,我们需要取得构成球体的顶点数量,实际上就是把360除以D φ的值和90除以Dρ值相乘。(如果你在创建一个球体而不是天空顶,你需要把180除以Dρ)
(360 / D φ) * (90 / Dρ) = 天空顶的顶点数量
然而,由于我们使用的是三角形带来连接这些点,而每一个三角形带需要4个顶点,这样我们就需要将这个结果乘以4,得到实际要循环来产生球体或天空顶的次数。请看一个例子,假定我们设定D φ = 15,Dρ = 15,为了生成圆顶,我们需要重复的次数是:
顶点数量 = (360 / 15) * (90 / 15) * 4 = 576
这就是正确的顶点数量,你马上会看到我们将它用于实行。
这篇文章的目的是做到与编程接口(API)无关,在开始编程之前,我们需要考虑一些东西。本节的代码很容易理解,先要使用一些优化手段。如果你使用的是Direct3D,那么顶点缓冲是一个不错的选择,而在OpenGL中则是顶点数组。本文所展示的技术演示使用的是OpenGL。代码写作的原则是让人容易理解,你可以将其优化,并以最符合你的游戏的需要的方式来使用它。
请看这一小段代码:
Vertex *Vertices;
int theta, phi;
float rad;
int dtheta = 15;
int dphi = 15;
int NumVertices = (int) ((360/dtheta) * (90/dphi) * 4);
Vertices = new VERTEX[NumVertices];
我们首先设置好我们需要的变量值,Vertices变量将储存我们所需要用来渲染球体或圆顶的顶点(如果要渲染一个球体,注意要相应地调整NumVertices的计算公式)。rad变量是圆顶的半径,而theta和phi则是我们产生圆顶的循环变量,dtheta和dphi我们已经解释过了。
注意:VERTEX结构类型随游戏需要而决定,我们这里的结构类型如下:
typedef struct { float x, y, z; DWORD color; float u, v; } VERTEX;
我们来看看我们的代码:
int n = 0;
for (phi = 0; phi <= 90 - dphi; phi += (int)dphi)
{
for (theta = 0; theta <= 360 - dtheta; theta += (int)dtheta)
{ Vertices[n].x = radius * sinf(phi * DTOR) * cosf(theta DTOR);
Vertices[n].y = radius * sinf(phi * DTOR) * sinf(theta * DTOR);
Vertices[n].z = radius * cosf(phi * DTOR);
n++;
Vertices[n].x = radius * sinf((phi + dphi) * DTOR) * cosf(theta* DTOR);
Vertices[n].y = radius * sinf((phi + dphi) * DTOR) * sinf(theta * DTOR);
Vertices[n].z = radius * cosf((phi + dphi) * DTOR);
n++;
Vertices[n].x = radius * sinf(phi * DTOR) * cosf((theta + dtheta) * DTOR);
Vertices[n].y = radius * sinf(phi * DTOR) * sinf((theta + dtheta) * DTOR);
Vertices[n].z = radius * cosf(phi * DTOR);
n++;
if (phi > -90 && phi < 90)
{ Vertices[n].x = radius * sinf((phi + dphi) * DTOR) * cosf((theta + dtheta) * DTOR);
Vertices[n].y = radius * sinf((phi + dphi) * DTOR) * sinf((theta + dtheta) * DTOR);
Vertices[n].z = radius * cosf((phi + dphi) * DTOR);
n++;
}
}
}
上面的代码就是实际上生成圆顶的代码。你可能会注意到DTOR宏的使用,这是因为三角函数只接受弧度作为输入参数,而我们使用的是角度,因此我们需要将角度转化为弧度。DTOR就是一个简单的宏,它的定义如下:
#define PI 3.1415926535897f
#define DTOR (PI / 180.0f)
同时别忘了定义PI,在我们对圆顶进行纹理映射时,它也非常有用。
回到我们的代码,这里我们只是应用了我们前面所学过的东西来获取球面上每一个顶点的x, y, z坐标,并把这些顶点以三角形带的方式保存在Vertices指向的地址中。
我们首先创建了一个新的变量n,这个变量用来对顶点数组进行存取,现在有一个双层循环,第一个循环范围为0 到 90 - dphi,以避免重复的顶点。在另一个循环,theta则从0变化到360 - dtheta 来生成圆顶。如果你想生成一个球体,你只需要修改第一个循环,使其在-90到90 - dphi间循环。同时要记得修改NumVertices变量的计算公式,不然你的程序就会出错。
圆顶(或者球体)的生成基础还是我们前面看到的球面坐标公式,并使用这些值来创建三角形带。基本上,对于三角形带上的点,我们只需要将相应的数值填入公式。
那么,我们现在已经创建好了我们的圆顶,如果你使用相应的API,把渲染模式设定为线框模式,并渲染我们顶点数组中的点(使用三角形带),你应该会看到如图3所示的图形。如果你没有看到如图3的结果,那你就得检查你所有的值是否正确,在生成圆顶的代码中是否错误地把一个sin函数写成了cos函数。这里的一个错误或许能够顺利通过编译,但结果可能大相径庭,你也可以试试生成其他一些形状。
图3. 圆顶的线框模型渲染结果
这个线框模型非常好,并可以用作产生一些效果,但对于我们所想要的天空顶来说,它还没有一个纹理,实际上不能做任何事情。为此,为了我们的天空顶,让我们向更有挑战性的地方继续前进。
把天空图贴到天空顶上
对球体进行纹理映射有点麻烦,正确的映射需要做一些工作。选择的映射方式不同,我们在对一个球体进行纹理映射时遇到的问题也不同。在图4中你可以看到,将平坦的纹理映射到一个球体上,在接缝处纹理被拉伸了。所以,这种方法产生的视觉效果不是我们所想要的。
图4. 把平坦的纹理映射到一个球体上
因此,根据这一点,我们将不使用平坦的纹理映射,而取而代之的是球面映射,是不是很有意义?不过,这个方法也不是十全十美,如果你使用的不是无缝纹理或者使用的纹理衔接得不好,接缝就很显眼了。最大的问题是在球体的两极,纹理将在那里聚积到一点。幸好,好的纹理可以把这些问题减到最小。