一、概述
电影《泰坦尼克号》想必大家都很熟悉。在影片开头那个堪称最经典的镜头中,泰坦尼克号徐徐地离开南开普顿港口驶向茫茫的大西洋, 杰克和露茜伸展双臂站在船头……其实在整个影片拍摄中,泰坦尼克号模型从未在大海中行驶过,都是在三维动画制作的“数字海洋”中拍摄完成的。
在PC机上实时生成海水动画在过去几乎是不可思议的事。但是在NVIDIA公司1999年发布显卡Geforce 256之后,一切都变得可能,PC机上实时渲染功能越来越强。Geforce 256是世界上第一款被叫做图形处理单元(graphics processing unit,GPU)的显卡,最革命性的改进在于将光影转换引擎(Transform and Lighting,T&L)集成到了GPU中,从而大大降低了CPU的负担,GPU的并行处理功能一举突破了以往在复杂的几何计算过程中,CPU速度过慢所引发的瓶颈。随后,NVIDIA推出了Geforce3,更是为桌面显卡开创了一个新的时代。GeForce3是第一款可编程的GPU,拥有独立的顶点渲染单元(vertex shader)和象素渲染单元(pixel shader),可用以实现丰富的特效和逼真的视觉效果。
目前,水波的绘制算法已有很多种,比如:Fourier合成技术、perlin noise建模以及基于物理模型的Navier Stokes方程等等。《水世界》, 《泰坦尼克号》这些电影就是用FFT(快速离散傅利叶变换)建模来绘制逼真的海水。但这种方法比较复杂,速度也慢一些,因为FFT运算只能在CPU上运算,无法使用GPU的功能。最为简单的水波生成方法是正弦波叠加技术,这是一种空间域的Fourier合成技术,通过将一系列不同频率、相位、振幅的的正弦波叠加起来产生所需的水波。其突出的优点是速度快,可由GPU的并行处理功能来承担建模运算,但缺点是只能模拟小振幅的水波。只有动态的水波还不够,必须要有一个特制的光照模型,否则生成的海水看上去一点也不真实。本文应用vertex shader实现多个正弦波叠加来拟合动态的水面;使用立方体纹理图(cube map)模拟海水的外部环境,用pixel shader来完成特制的光照模型。在800x600的分辨率下,能达到每秒30帧的速度(使用的显卡是Geforce FX 5200),实时地展现具有真实感的海水动画。
小知识: 顶点渲染(Vertex Shader)是GPU构建三维空间顶点的手段。顶点(Vertex)是几何图形中的基本元素,三个顶点能构成三角形,并且一定数目的三角形可以组成不同规模和复杂程度的空间物体。当三维引擎把画面上的物体传送给GPU时,事实上它传送的就是这些物体的顶点。每个顶点都包含着许多信息,首先是X、Y、Z这些3维坐标位置、颜色,另外还包括它的法线、纹理坐标等等。GPU顶点渲染单元主要的任务就是计算出这些顶点所包含各类信息。经过顶点渲染的处理后,得到的是经转换并且照明的顶点。下一个步骤是裁剪,将场景之外的顶点都裁剪掉。接着是背面剔除,把从视角看不见的那些顶点都去掉。最后由像素渲染单元(Pixel Shader)结合色彩、光源和纹理等信息,计算出每个像素的最终颜色,将真实的3D场景换成屏幕上的2D场景。打个比方,顶点渲染就好象是建筑工人,他们的作用就是搭建出一个教堂的框架,而像素渲染是艺术家,他们将空白的墙面涂上各种色彩,完成整个的建筑工程。 |
二、水波的建模
运用正弦波叠加技术的波纹函数定义为:
其中:Ai为第i个正弦波的振幅
L i为第i个正弦波的波长
Dix、Diy为第i个正弦波的前进方向
S i为第i个正弦波的前进速度
t为时间
上述的函数实际上定义了t时刻水波的高度场。当t连续变化时,就得到了水波的运动。将该波纹函数作为位移函数扰动景物表面,可产生所需水波的动态效果。
理论上,任意波形都可使用无数个正弦波叠加而成。不过为了实时生成水波,本文只使用了四个正弦波叠加,因此速度是保证了,但是形成的动态水面只是一个粗糙的水面轮廓。为了弥补细节上的缺失,可将一个预先定制的Bump mapping(凹凸纹理映射)映射到水面轮廓上,来展示水面细小的波纹,从而提高真实感(如图1所示)。
图2 |
图1 |
凹凸纹理映射是一种无需修改表面几何模型,通过景物表面法向量的扰动,导致表面光亮度的突变(光亮度是法向量的函数),是模拟表面凹凸不平质感的有效方法。向量(0,0,1)在凹凸纹理映射中表示未经扰动的法向量,扰动后的法向量存储在一个二维数组里,称为凹凸映射图。对高度图(height field)进行处理,就可以构造一个凹凸映射图。在具有的高度图中,用h(i , j)表示储存在(i , j)位置的高度值。于是可先计算出该位置的在U和V方向上的切向量,进而通过U(i , j)与V(i , j)的叉积得到该处的法向量(公式见下)。
显然前述运用正弦波叠加技术生成的波纹曲面的法向量不会是(0,0,1),所以无法直接使用凹凸映射图。解决的方法是:在波纹曲面的每一个顶点位置,利用该处的切向量构造一个切线坐标系(见图2)。在该局部坐标系下,顶点的法向量总是指向z轴正方向,即(0,0,1),这样就可以根据二维的纹理地址直接获取凹凸映射图里扰动后的法向量。剩下的问题只要在每个顶点处利用下面这个矩阵,该矩阵可将对应的凹凸映射图法向量从局部切线空间转换到世界坐标系(B、T、N分别是切线空间的三个坐标轴)。
其中:
小知识: 传统的纹理贴图技术只能将平面贴图机械地附着在由多边形构成的3D骨架上,无法表现出物体表面的凹凸起伏。想要实现褶皱的效果,只能在纹理贴图上添加一些阴影,而这些阴影不会即时改变,因此一旦遇到动态光源,便会原形毕露,给人的感觉很不真实。凹凸纹理映射(Bump mapping)采用一种新的处理方式:不改变物体的几何造型,使用凹凸贴图为三角形上的每个像素赋予虚拟的法线。因此,光线反射不是按照真正的平面三角形法线计算,而是按照虚拟的法线计算出来。结果让一块并非“真实”凹凸的区域看起来有3D的感觉。 |
directx 9.0带来的一项新功能是高级渲染语言(High Level Shading Language,简称HLSL)。在directx 9.0 推出以前,如果不使用nvidia公司推出的cg,只能用汇编语言编写vertex shader和pixel shader,非常麻烦。使用HLSL,不用再考虑那些硬件细节,把精力集中在算法上,而且HLSL是高级语言,代码非常容易阅读。本文的演示程序就采用HLSL语言编写在GPU运行的顶点渲染和像素渲染的自定义程序,来替换原来固定的渲染流水线。具体的vertex shader代码如下:
//-----------------------------------------------------------------------------
// Global variables
//-----------------------------------------------------------------------------
float4x4 mWorldViewProj: register(c0); // Composite World-View-Projection Matrix
float4 vCamera: register(c4); // 视点
float4 vWaveHeight: register(c5); // 正弦波振幅A
float4 vWaveOffset: register(c6);
float4 vWaveSpeed: register(c7); // 正弦波的波速S
float4 vWaveDirX: register(c8); //正弦波的前进方向的Y分量Dix/L
float4 vWaveDirY: register(c9); // 正弦波的前进方向的Y分量Diy/L
float fTime: register(c10); // Time parameter. This keeps increasing
float4 vDistortion: register(c11);
float4x4 mWorld: register(c12); // World Martix transformation
//-----------------------------------------------------------------------------
// Vertex shader input structure
//-----------------------------------------------------------------------------
struct VS_INPUT
{
float4 vPosition : POSITION; //position in object space
float3 vNormal : NORMAL; //normal
float4 vWaveHeightScale : COLOR; //Wave Height Scale
float2 tcCoord : TEXCOORD0; //texture coordinates
float3 vTangent : TANGENT; //tangent
};
//-----------------------------------------------------------------------------
// Vertex shader output structure
//-----------------------------------------------------------------------------
struct VS_OUTPUT
{
float4 vPosition : POSITION; // vertex position
float2 tcBump0 : TEXCOORD0; //texture coordinates
float2 tcBump1 : TEXCOORD1; //texture coordinates
float3 vEye : TEXCOORD2; //eye vector
float3x3 mToWorld: TEXCOORD3;
};
//
// Main
//
VS_OUTPUT Main( const VS_INPUT input )
{
VS_OUTPUT output;
float4 fSin, fCos;
float4 vAngle = frac(input.tcCoord.x * vWaveDirX + input.tcCoord.y * vWaveDirY + (fTime * vWaveSpeed)) * 2*3.14159265f;
sincos(vAngle, fSin, fCos);
fSin = fSin * (1-input.vWaveHeightScale.x)*0.2 ;
fCos = fCos * (1-input.vWaveHeightScale.x)*0.2 ;
float4 vNewPos;
vNewPos.xyz = input.vPosition.xyz + dot(fSin, vWaveHeight) * input.vNormal.xyz;
vNewPos.w = 1.0f;
output.vPosition = mul(vNewPos, mWorldViewProj );
// tangent to world
float2 vTemp;
vTemp.x = dot(fCos*vWaveHeight, vWaveDirX)*2*3.14;
vTemp.y = dot(fCos*vWaveHeight, vWaveDirY)*2*3.14;
float3 vNormal = {-vTemp.x, -vTemp.y, input.vNormal.z};
vNormal = normalize(mul(vNormal, mWorld));
float3 vTangent = {input.vTangent.x, input.vTangent.y , vTemp.x};
vTangent = normalize(mul(vTangent, mWorld));
float3 vBinormal = cross(vNormal,vTangent);
vBinormal = normalize(mul(vBinormal, mWorld));
output.mToWorld[0] = vBinormal;
output.mToWorld[1] = vTangent;
output.mToWorld[2] = vNormal;
//view vector
output.vEye = normalize(vCamera - mul(vNewPos, mWorld));
//
output.tcBump0.xy = frac(fTime * vDistortion.xy) + input.tcCoord.xy;
output.tcBump1.xy = frac(fTime * vDistortion.zw) + input.tcCoord.xy;
return output;
}
三、海水的光照模型
现在的GPU都支持立方体纹理图(cube map),常用它来近似地表示景物表面对周围环境的反射,见图3。本文使用立方体纹理图来模拟海水的周围的天空。立方体纹理图由六个正方形2D纹理组成,分别对应于立方体六个面,纹理坐标定义为三维向量,从立方体中心指向其表面一点。图4给出了立方体纹理映射的整个过程。入射视线I从视点到达景物表面O点,O点的法向量为N,经物体的表面反射后生成一条反射光线R。基于镜面反射,入射角等于反射角,因而反射光线。 该反射光线与立方体纹理图相交于E点。所以景物表面O点的反射颜色就是E点处的纹理值的函数,显然反射光线R就是立方体纹理图的三维纹理坐标。
图3 |
图4 |
海水作为透明物体,其漫反射颜色Cdiffuse由两部分按一定比例混合而成,一是来自天空的反射Csky,二是来自水下光线的折射Cunderwater。混合比例是由Fresnel系数决定的,,(其中Ssky、Sunderwater为比例系数)来自天空反射的颜色可通过相应的三维纹理坐标查找立方体纹理图获得,而水下光线折射在本光照模型中假设为绿色。Fresnel系数的取值范围是[0.0,1.0],如果Fresnel系数是0.8,那么反射光线比例为80%,而通过水下折射到.达海水表面的光线的比例为20%。当视线与法向量的夹角接近90度时,Fresnel系数达到最大;当视线与法向量的夹角接近0度时,Fresnel系数最小。所以,在日常生活中,水下的物体要在水面的正上方观察时才能看到;此时视线与法向量的夹角接近0度,Fresnel系数较小,于是海水颜色中Cunderwater成分较多,因而才能看清水下物体。Fresnel系数R(β)可由下面简化的近似公式算出:R(β) =1-cos(β),β为视线与法向量的夹角。
图5 |
当太阳照耀在海面上,会形成强烈的高光。为了模拟这种效果,本文光照模型采用了一种简单的办法来计算海面高光Cspecular。由于立方体纹理图中只有太阳所处的纹理颜色中绿色成分最多,只要对纹理图中颜色的绿色通道那部分求八次幂就可形成一张光泽图(gloss map),如图5所示。再由光泽图来确定景物表面上每个点的高光Cspecular,,(其中Cwaterhighcolor设为白色,Cskygreen为纹理图中颜色绿色通道的值,Sspecular为比例系数)。
综合前面的叙述,海水的颜色。Cambient为周围环境光,,其中Sambient为比例系数。图6为演示程序截图。
图6 |
具体的pixel shader代码(HLSL)如下:
//-----------------------------------------------------------------------------
// Global variables
//-----------------------------------------------------------------------------
sampler BumpTex0;
sampler BumpTex1;
sampler CubeTex;
float4 watercolor : register(c0);
float4 waterhighcolor : register(c1);
//-----------------------------------------------------------------------------
// Vertex shader input structure
//-----------------------------------------------------------------------------
struct PS_INPUT
{
float2 tcBump0 : TEXCOORD0; //texture coordinates
float2 tcBump1 : TEXCOORD1; //texture coordinates
float3 vEye : TEXCOORD2; //eye vector
float3x3 mToWorld: TEXCOORD3;
};
//-----------------------------------------------------------------------------
// Vertex shader output structure
//-----------------------------------------------------------------------------
struct PS_OUTPUT
{
float4 vColor : COLOR0;
};
//
// Main
//
PS_OUTPUT Main(PS_INPUT input)
{
// zero out members of output
PS_OUTPUT output;
// sample appropriate textures
float3 vNormal0 = tex2D(BumpTex0, input.tcBump0).xyz;
float3 vNormal1 = tex2D(BumpTex1, input.tcBump1).xyz;
float3 vNormal = (vNormal0 + vNormal1 )/2;
vNormal = mul(vNormal, input.mToWorld);
float eDotN = dot(input.vEye, vNormal);
float3 vEyeReflected = 2* eDotN * vNormal - input.vEye;
float4 reflection = texCUBE(CubeTex, vEyeReflected);
float Fresnel = (1 - eDotN)*reflection.g ;
float4 diffuse = watercolor * 0.5 * eDotN;
float4 color = saturate(lerp(diffuse,reflection * 1.2 * eDotN,Fresnel) + reflection * 0.3);
float4 specular = waterhighcolor * pow(reflection.g,8) * eDotN ;
output.vColor = saturate(color + specular);
return output;
}
四、结束语
在中国,一般的电脑杂志上基本上找不到关于图形学的文章;而在网上论坛里,又经常看到不少网友急切地想掌握图形学,却不得要领。针对这种状况,特写此文,希望能抛砖引玉。与本文配套的演示程序需要在directx 9.0环境下运行,显卡必须支持PS2.0、VS2.0。演示源程序与执行代码可在 下载。
参考书目
1. Eric Lengyel著,詹海生等译,3D游戏与计算机图形学中的数学方法,清华大学出版社,2004
2. 鲍虎军,金小刚,彭群生,计算机动画的算法基础,浙江大学出版社,2000
3. Randima Fernando, Mark J. Kilgard,The Cg Tutorial: The Definitive Guide to Programmable Real-Time Graphics,Addison-Wesley Pub Co,2003
4. Wolfgang F. Engel ,Direct3D ShaderX: Vertex and Pixel Shader Tips and Tricks,Wordware Publishing,2003
5. Wolfgang F. Engel ,ShaderX2: Introductions and Tutorials with DirectX 9.0,Wordware Publishing,2003
6. Tomas Akenine-Moller, Eric Haines,Real-Time Rendering (2nd Edition),AK Peters, Ltd.,2002
作者: 沈 璐