unity3d 水波特效教程
看到这幅动画(如果没有出现,请耐心稍等片刻),你也许不会相信它其实是用电脑制作出来的,这就是“水波”特效的魅力所在。
水波的物理特性回顾
在介绍编程实现之前,我们先来回顾高中物理课上学到的关于水波的知识。水波具有以下几个特性:
- 扩散:当向水中投一块石头时,会看到以石头入水点为圆心形成的一圈圈水波。但实际上,水波上的任何一点在任何时候都是以自己为圆心向四周扩散的。之所以会形成环状水波,是因为水波内部因扩散的对称而相互抵消了。
- 衰减:由于水存在阻尼,若没有阻尼,当在水池中投入石头,水波将会永不停止地震荡下去。
- 水的折射:因为水波上不同地点的倾斜角度不同,受水的折射影响,我们从观察点垂直往下看到的水底并非在观察点的正下方,而是有一定的偏移。若不考虑水面上部的光线反射,这就是我们能感觉到水波形状的原因。
- 反射:水波遇到障碍物会发生反射。
- 衍射:当在水池中央放上一块礁石,或放置一个中间有缝的隔板时,就能观察到水波的衍射现象。不过在本程序里暂未体现。
有了这些特性,再结合数学和几何知识,我们就可以模拟出真实的水波。然而,若曾用 3DMax 制作过水波动画,就会知道渲染出一幅真实形状的水波画面至少需要几十秒。而我们现在需要实时渲染,每秒至少渲染 20 帧才能使水波平滑显示。考虑到电脑运算速度,我们不能按照正弦函数或精确公式来构造水波,也不能使用乘除法以及 sin、cos 函数,只能采用一种取近似值的快速算法。尽管这种算法存在一定误差,但为了满足实时动画的要求,只能如此。
建立波能缓冲区
首先,我们要建立两个与水池图象大小相同的数组 buf1[PoolWidth*PoolHeight] 和 buf2[PoolWidth*PoolHeight](其中 PoolWidth 为水池图象的像素宽度,PoolHeight 为水池图象的像素高度),用于保存水面上每一个点的前一时刻和后一时刻的波幅数据。由于波幅代表了波的能量,所以我们将这两个数组称为波能缓冲区。水面初始状态为平面,各点波幅都为 0,因此这两个数组的初始值也都为 0。
推导波幅计算公式
假设存在一个一次公式,可在任意时刻根据某一个点周围前、后、左、右四个点以及该点自身的振幅来推算出下一时刻该点的振幅。这样,我们就有可能用归纳法求出任意时刻这个水面上任意一点的振幅。
如左图所示,某一时刻,X0 点的振幅除受自身振幅影响外,还受其周围前、后、左、右四个点(X1、X2、X3、X4)的影响,且这四个点对 X0 点的影响力均等。那么我们可以假设这个一次公式为:
[X0' = a(X1 + X2 + X3 + X4) + bX0]
其中,a、b 为待定系数,X0' 为 X0 点下一时刻的振幅,X0、X1、X2、X3、X4 为当前时刻的振幅。
下面来求解 a 和 b。假设水的阻尼为 0,在这种理想条件下,水的总势能保持不变,即任何时刻所有点的振幅之和保持不变,可得到公式:
[X0' + X1' + \cdots + Xn' = X0 + X1 + \cdots + Xn]
将每一个点都按照上述公式计算,然后代入上式,得到:
[(4a + b)X0 + (4a + b)X1 + \cdots + (4a + b)Xn = X0 + X1 + \cdots + Xn]
由此可得:
[4a + b = 1]
找出一个最简解:(a = \frac{1}{2})、(b = -1)。因为 (\frac{1}{2}) 可以用移位运算符 “>>” 来实现,无需进行乘除法,所以这组解是最适用且最快的。那么最后得到的公式就是:
[X0' = \frac{X1 + X2 + X3 + X4}{2} - X0]
综上所述,已知某一时刻水面上任意一点的波幅,在下一时刻,任意一点的波幅就等于与该点紧邻的前、后、左、右四点的波幅之和除以 2,再减去该点的波幅。
需要注意的是,水在实际中是存在阻尼的,若使用上述公式,一旦在水中增加一个波源,水面将永不停止地震荡下去。所以,还需要对波幅数据进行衰减处理,让每一个点在经过一次计算后,波幅都比理想值按一定比例降低。经过测试,衰减率用 (\frac{1}{32})(即 (\frac{1}{2^5}))比较合适,可通过移位运算快速获得。
计算波幅数据的代码实现
到这里,水波特效制作中最关键的部分已经完成,下面是源程序中计算波幅数据的代码:
// 计算波能数据缓冲区
void RippleSpread()
{
for (int i = BACKWIDTH; i < BACKWIDTH * (BACKHEIGHT - 1); i++)
{
// 波能扩散
buf2[i] = ((buf1[i - 1] + buf1[i + 1] + buf1[i - BACKWIDTH] + buf1[i + BACKWIDTH]) >> 1) - buf2[i];
// 波能衰减
buf2[i] -= buf2[i] >> 5;
}
// 交换波能数据缓冲区
short *ptmp = buf1;
buf1 = buf2;
buf2 = ptmp;
}
根据波幅数据进行页面渲染
因为水的折射,当水面不与我们的视线垂直时,我们看到的水下景物并非在观察点的正下方,而是存在一定偏移。偏移程度与水波的斜率、水的折射率和水的深度都有关系,若进行精确计算显然不现实,我们只需做线性的近似处理即可。由于水面越倾斜,看到的水下景物偏移量越大,所以可以近似地用水面上某点的前后、左右两点的波幅之差来代表所看到水底景物的偏移量。
在程序中,用一个页面装载原始图象,用另一个页面进行渲染。先用 Lock 函数锁定两个页面,取得指向页面内存区的指针,然后根据偏移量将原始图象上的每一个像素复制到渲染页面上。页面渲染的代码如下:
// 根据波能数据缓冲区对离屏页面进行渲染
void RenderRipple()
{
// 锁定两个离屏页面
DDSURFACEDESC ddsd1, ddsd2;
ddsd1.dwSize = sizeof(DDSURFACEDESC);
ddsd2.dwSize = sizeof(DDSURFACEDESC);
lpDDSPic1->Lock(NULL, &ddsd1, DDLOCK_WAIT, NULL);
lpDDSPic2->Lock(NULL, &ddsd2, DDLOCK_WAIT, NULL);
// 取得页面像素位深度和页面内存指针
int depth = ddsd1.ddpfPixelFormat.dwRGBBitCount / 8;
BYTE *Bitmap1 = (BYTE*)ddsd1.lpSurface;
BYTE *Bitmap2 = (BYTE*)ddsd2.lpSurface;
// 进行页面渲染
int xoff, yoff;
int k = BACKWIDTH;
for (int i = 1; i < BACKHEIGHT - 1; i++)
{
for (int j = 0; j < BACKWIDTH; j++)
{
// 计算偏移量
xoff = buf1[k - 1] - buf1[k + 1];
yoff = buf1[k - BACKWIDTH] - buf1[k + BACKWIDTH];
// 判断坐标是否在窗口范围内
if ((i + yoff) < 0) { k++; continue; }
if ((i + yoff) > BACKHEIGHT) { k++; continue; }
if ((j + xoff) < 0) { k++; continue; }
if ((j + xoff) > BACKWIDTH) { k++; continue; }
// 计算出偏移像素和原始像素的内存地址偏移量
int pos1, pos2;
pos1 = ddsd1.lPitch * (i + yoff) + depth * (j + xoff);
pos2 = ddsd2.lPitch * i + depth * j;
// 复制像素
for (int d = 0; d < depth; d++)
{
Bitmap2[pos2++] = Bitmap1[pos1++];
}
k++;
}
}
// 解锁页面
lpDDSPic1->Unlock(&ddsd1);
lpDDSPic2->Unlock(&ddsd2);
}
增加波源
俗话说:“无风不起浪”,为了形成水波,我们必须在水池中加入波源,可想象成向水中投入石头。波源的大小和能量与石头的半径和扔石头的力量有关。我们只需修改波能数据缓冲区 buf,让它在石头入水的地点产生一个负的“尖脉冲”,即让 buf[x,y] = -n。经过实验,n 的范围在(32 ~ 128)之间比较合适。
控制波源半径也很简单,以石头入水中心点为圆心,画一个以石头半径为半径的圆,让这个圆中所有的点都产生这样一个负的“尖脉冲”即可(这里也做了近似处理)。增加波源的代码如下:
// 增加波源
void DropStone(int x, // x 坐标
int y, // y 坐标
int stonesize, // 波源半径
int stoneweight) // 波源能量
{
// 判断坐标是否在屏幕范围内
if ((x + stonesize) > BACKWIDTH || (y + stonesize) > BACKHEIGHT || (x - stonesize) < 0 || (y - stonesize) < 0)
return;
for (int posx = x - stonesize; posx < x + stonesize; posx++)
{
for (int posy = y - stonesize; posy < y + stonesize; posy++)
{
if ((posx - x) * (posx - x) + (posy - y) * (posy - y) < stonesize * stonesize)
{
buf1[BACKWIDTH * posy + posx] = -stoneweight;
}
}
}
}
总结
至此,水波特效的制作原理已全部揭示。在上述推导过程中,每一步都进行了很多看似过分的近似处理,但事实证明,用这种方法在速度和图象效果上都能获得不错的结果。源程序中有非常详尽的注释,仔细研究应该不难理解。
这个程序是 Win32 下的 DirectX 编程,未使用任何包装库。在我的电脑(AMDK6 - 200、2M VRam、64M SRam)上,320x240 的画面大小每秒可达到 25 帧。与前几个程序不同,该程序使用了窗口模式,调试起来十分方便。若你对窗口模式编程不熟悉,这个程序是一个很好的学习示例。
这种用数据缓冲区对图象进行水波处理的方法,最大的优点是程序运算和显示的速度与水波的复杂程度无关,无论水面是风平浪静还是波涛汹涌,程序的帧率(fps)始终保持不变,研究程序代码就能发现这一点。实际上,掌握这种方法后进行推广,完全可以制作出其他特殊效果,如烟雾、大气、阳光等。我目前也正在研究这些特效的制作,相信不久后会有新的成果。