說明:
本文算法部分整理自 GameRes 上的資料,原作者 Imagic。我隻是在學習 Android 的過程中,想到這個特效,然後就在Android 上實作出來,并在源算法的基礎上添加了雨滴滴落特效,以及劃過水面時的漣漪特效。 該程式在模拟器和真機上運作速度都較慢,需要進一步優化或使用 JNI 實作。
示例程式下載下傳:http://www.cppblog.com/Files/kesalin/RippleDemo.zip
基礎知識:
在講解代碼之前,我們來回顧一下在高中的實體課上我們所學的關于水波的知識。水波有擴散,衰減,折射,反射,衍射等幾個特性:
擴散:當你投一塊石頭到水中,你會看到一個以石頭入水點為圓心所形成的一圈圈的水波,這裡,你可能會被這個現象所誤導,以為水波上的每一點都是以石頭入水點為中心向外擴散的,這是錯誤的。實際上,水波上的任何一點在任何時候都是以自己為圓心向四周擴散的,之是以會形成一個環狀的水波,是因為水波的内部因為擴散的對稱而互相抵消了。
衰減:因為水是有阻尼的,否則,當你在水池中投入石頭,水波就會永不停止的震蕩下去。
折射:因為水波上不同地點的傾斜角度不同,是以我們從觀察點垂直往下看到的水底并不是在觀察點的正下方,而有一定的偏移。如果不考慮水面上部的光線反射,這就是我們能感覺到水波形狀的原因。
反射:水波遇到障礙物會反射。
衍射:在水池中央放上一塊礁石,或放一個中間有縫的隔闆,那麼就能看到水波的衍射現象了。
算法推導:
好了,有了這幾個特性,再運用數學和幾何知識,我們就可以模拟出真實的水波了。但是,如果你曾用3DMax做過水波的動畫,你就會知道要渲染出一幅真實形狀的水波畫面少說也得好幾十秒,而我們現在需要的是實時的渲染,每秒種至少也得渲染20幀才能使得水波得以平滑的顯示。考慮到電腦運算的速度,我們不可能按照正弦函數或精确的公式來構造水波,不能用乘除法,更不能用sin、cos等三角函數,隻能用一種取近似值的快速算法,盡管這種算法存在一定誤差,但是為了滿足實時動畫的要求,我們不得不這樣做。
首先我們要建立兩個與水池圖象一樣大小的數組buf1[PoolWidth * PoolHeight]和buf2[PoolWidth * PoolHeight](PoolWidth 為水池圖象的象素寬度、PoolHeight 為水池圖象的象素高度),用來儲存水面上每一個點的前一時刻和後一時刻波幅資料,因為波幅也就代表了波的能量,是以在後面我們稱這兩個數組為波能緩沖區。水面在初始狀态時是一個平面,各點的波幅都為0,是以,這兩個數組的初始值都等于0。
下面來推導計算波幅的公式
我們假設存在這樣一個一次公式,可以在任意時刻根據某一個點周圍前、後、左、右四個點以及該點自身的振幅來推算出下一時刻該點的振幅,那麼,我們就有可能用歸納法求出任意時刻這個水面上任意一點的振幅。如左圖,你可以看到,某一時刻,X0點的振幅除了受X0點自身振幅的影響外,同時受來自它周圍前、後、左、右四個點(X1、X2、X3、X4)的影響(為了簡化,我們忽略了其它所有點),而且,這四個點對X0點的影響力可以說是機會均等的。那麼我們可以假設這個一次公式為:
X0’ = a * (X1 + X2 + X3 + X4) + b * X0 (公式1) |
a, b為待定系數,X0’ 為X0點下一時刻的振幅,
X0、X1、X2、X3、X4為目前時刻的振幅
下面我們來求解a和b。
假設水的阻尼為0。在這種理想條件下,水的總勢能将保持不變,水波永遠波動。也就是說在任何時刻,所有點的振幅的和保持不變。那麼可以得到下面這個公式:
X0’ + X1’ + ... + Xn’ = X0 + X1 + ... + Xn |
将每一個點用公式1替代,代入上式,得到:
(4a + b) * X0 + (4a + b) * X1 + ... (4a + b) * Xn = X0 + X1 + ... + Xn => 4a + b = 1 |
找出一個最簡解:a = 1/2、b = -1。
因為1/2可以用移位運算符 “>>” 來進行,不用進行乘除法,是以,這組解是最适用的而且是最快的。那麼最後得到的公式就是:
X0’=(X1 + X2 + X3 + X4)/ 2 - X0 |
好了,有了上面這個近似公式,你就可以推廣到下面這個一般結論:已知某一時刻水面上任意一點的波幅,那麼,在下一時刻,任意一點的波幅就等于與該點緊鄰的前、後、左、右四點的波幅的和除以2、再減去該點的波幅。
應該注意到,水在實際中是存在阻尼的,否則,用上面這個公式,一旦你在水中增加一個波源,水面将永不停止的震蕩下去。是以,還需要對波幅資料進行衰減處理,讓每一個點在經過一次計算後,波幅都比理想值按一定的比例降低。這個衰減率經過測試,用1/32比較合适,也就是1/2^5。可以通過移位運算很快的獲得。
到這裡,水波特效算法中最艱難的部分已經明了,下面是Android 源程式中計算波幅資料的代碼。
// 某點下一時刻的波幅算法為:上下左右四點的波幅和的一半減去目前波幅,即 // X0' =(X1 + X2 + X3 + X4)/ 2 - X0 // +----x3----+ // + | + // + | + // x1---x0----x2 // + | + // + | + // +----x4----+ // void rippleSpread() { int pixels = m_width * (m_height - 1); for (int i = m_width; i < pixels; ++i) { // 波能擴散:上下左右四點的波幅和的一半減去目前波幅 // X0' =(X1 + X2 + X3 + X4)/ 2 - X0 // m_buf2[i] = (short)(((m_buf1[i - 1] + m_buf1[i + 1]+ m_buf1[i - m_width] + m_buf1[i + m_width]) >> 1) - m_buf2[i]); // 波能衰減 1/32 // m_buf2[i] -= m_buf2[i] >> 5; } //交換波能資料緩沖區 short[] temp = m_buf1; m_buf1 = m_buf2; m_buf2 = temp; } |
渲染:
然後我們可以根據算出的波幅資料對頁面進行渲染。
因為水的折射,當水面不與我們的視線相垂直的時候,我們所看到的水下的景物并不是在觀察點的正下方,而存在一定的偏移。偏移的程度與水波的斜率,水的折射率和水的深度都有關系,如果要進行精确的計算的話,顯然是很不現實的。同樣,我們隻需要做線性的近似處理就行了。因為水面越傾斜,所看到的水下景物偏移量就越大,是以,我們可以近似的用水面上某點的前後、左右兩點的波幅之差來代表所看到水底景物的偏移量。
在程式中,用一個頁面裝載原始的圖像,用另外一個頁面來進行渲染。先取得指向兩個頁面記憶體區的指針 src 和 dst,然後用根據偏移量将原始圖像上的每一個象素複制到渲染頁面上。進行頁面渲染的代碼如下:
void rippleRender() { int offset; int i = m_width; int length = m_width * m_height; for (int y = 1; y < m_height - 1; ++y) { for (int x = 0; x < m_width; ++x, ++i) { // 計算出偏移象素和原始象素的記憶體位址偏移量 : //offset = width * yoffset + xoffset offset = (m_width * (m_buf1[i - m_width] - m_buf1[i + m_width])) + (m_buf1[i - 1] - m_buf1[i + 1]); // 判斷坐标是否在範圍内 if (i + offset > 0 && i + offset < length) { m_bitmap2[i] = m_bitmap1[i + offset]; } else { m_bitmap2[i] = m_bitmap1[i]; } } } } |
增加波源:
俗話說:無風不起浪,為了形成水波,我們必須在水池中加入波源,你可以想象成向水中投入石頭,形成的波源的大小和能量與石頭的半徑和你扔石頭的力量都有關系。知道了這些,那麼好,我們隻要修改波能資料緩沖區buf,讓它在石頭入水的地點來一個負的“尖脈沖”,即讓buf[x,y] = -n。經過實驗,n的範圍在(32 ~ 128)之間比較合适。
控制波源半徑也好辦,你隻要以石頭入水中心點為圓心,畫一個以石頭半徑為半徑的圓,讓這個圓中所有的點都來這麼一個負的“尖脈沖”就可以了(這裡也做了近似處理)。
增加波源的代碼如下:
// stoneSize : 波源半徑 // stoneWeight : 波源能量 // void dropStone(int x, int y, int stoneSize, int stoneWeight) { // 判斷坐标是否在範圍内 if ((x + stoneSize) > m_width || (y + stoneSize) >m_height || (x - stoneSize) < 0 || (y - stoneSize) < 0) { return; } int value = stoneSize * stoneSize; short weight = (short)-stoneWeight; 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) < value) { m_buf1[m_width * posy + posx] = weight; } } } } |
如果我們想要模拟在水面劃過時引起的漣漪效果,那麼我們還需要增加新的算法函數 breasenhamDrop。
void dropStoneLine(int x, int y, int stoneSize, intstoneWeight) { // 判斷坐标是否在螢幕範圍内 if ((x + stoneSize) > m_width || (y + stoneSize) >m_height || (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) { m_buf1[m_width * posy + posx] = -40; } } } // xs, ys : 起始點,xe, ye : 終止點 // size : 波源半徑,weight : 波源能量 void breasenhamDrop (int xs, int ys, int xe, int ye, intsize, int weight) { int dx = xe - xs; int dy = ye - ys; dx = (dx >= 0) ? dx : -dx; dy = (dy >= 0) ? dy : -dy; if (dx == 0 && dy == 0) { dropStoneLine(xs, ys, size, weight); } else if (dx == 0) { int yinc = (ye - ys != 0) ? 1 : -1; for(int i = 0; i < dy; ++i){ dropStoneLine(xs, ys, size, weight); ys += yinc; } } else if (dy == 0) { int xinc = (xe - xs != 0) ? 1 : -1; for(int i = 0; i < dx; ++i){ dropStoneLine(xs, ys, size, weight); xs += xinc; } } else if (dx > dy) { int p = (dy << 1) - dx; int inc1 = (dy << 1); int inc2 = ((dy - dx) << 1); int xinc = (xe - xs != 0) ? 1 : -1; int yinc = (ye - ys != 0) ? 1 : -1; for(int i = 0; i < dx; ++i) { dropStoneLine(xs, ys, size, weight); xs += xinc; if (p < 0) { p += inc1; } else { ys += yinc; p += inc2; } } } else { int p = (dx << 1) - dy; int inc1 = (dx << 1); int inc2 = ((dx - dy) << 1); int xinc = (xe - xs != 0) ? 1 : -1; int yinc = (ye - ys != 0) ? 1 : -1; for(int i = 0; i < dy; ++i) { dropStoneLine(xs, ys, size, weight); ys += yinc; if (p < 0) { p += inc1; } else { xs += xinc; p += inc2; } } } } |
效果圖:
劃過水面時的漣漪特效 | 雨滴滴落水面特效 |
結語:
這種用資料緩沖區對圖像進行水波處理的方法,有個最大的好處就是,程式運算和顯示的速度與水波的複雜程度是沒有關系的,無論水面是風平浪靜還是波濤洶湧,程式的fps始終保持不變,這一點你研究一下程式就應該可以看出來。
優化處理:
上述進行中對水波波幅的計算是針對每一個像素的,效率比較低,尤其是在手機上運作,相當緩慢。我們可以利用線性插值進行優化,這樣可以将計算減少一半(MeshSize 為 2)或減少四分之三(MeshSize 為 4),效率得以大大提升,即使是在水機上也能較為流暢地運作。
在下面的代碼中,為了充分使用移位運算替代乘除法,MeshSize 必須為 2 的整次幂,MeshShift 就是其幂數,表示計算時的移位位數。代碼下載下傳連結:http://www.cppblog.com/Files/kesalin/RippleDemo_opt.zip
線性插值優化之後的水波擴散代碼如下:
static final int MeshSize = 2; static final int MeshShift = 1; int m_meshWidth; int m_meshHeight; m_meshWidth = m_width / MeshSize + 1; m_meshHeight = m_height / MeshSize + 1;; void rippleSpread() { m_waveFlag = false; int i = 0, offset = 0; for (int y = 1; y < m_meshHeight - 1; ++y) { offset = y * m_meshWidth; for (int x = 1; x < m_meshWidth - 1; ++x) { i = offset + x; m_buf2[i] = (short)(((m_buf1[i - 1] + m_buf1[i + 1] + m_buf1[i - m_meshWidth] + m_buf1[i + m_meshWidth]) >> 1) -m_buf2[i]); m_buf2[i] -= (m_buf2[i] >> 5); m_waveFlag |= (m_buf2[i] != 0); } } if (m_waveFlag){ m_waveFlag = false; for (int y = 1; y < m_meshHeight - 1; ++y) { offset = y * m_meshWidth; for (int x = 1; x < m_meshWidth - 1; ++x) { i = offset + x; m_bufDiffX[i] = (short)((m_buf2[i + 1] - m_buf2[i - 1]) >> 3); m_bufDiffY[i] = (short)((m_buf2[i + m_meshWidth] - m_buf2[i - m_meshWidth]) >> 3); m_waveFlag |= (m_bufDiffX[i] != 0 ||m_bufDiffY[i] != 0); } } } //交換波能資料緩沖區 short[] temp = m_buf1; m_buf1 = m_buf2; m_buf2 = temp; } |
既然波幅計算使用了線性插值,描繪時的代碼也許相應進行更改:
Point p1, p2, p3, p4; Point pRowStart, pRowEnd, p, rowStartInc, rowEndInc, pInc; void rippleRender() { int px = 0, py = 0, dx = 0, dy = 0; int index = 0, offset = 0; for (int j = 1; j < m_meshHeight; ++j) { offset = j * m_meshWidth; for (int i = 1; i < m_meshWidth; ++i) { index = offset + i; p1.x = m_bufDiffX[index - m_meshWidth - 1]; p1.y = m_bufDiffY[index - m_meshWidth - 1]; p2.x = m_bufDiffX[index - m_meshWidth]; p2.y = m_bufDiffY[index - m_meshWidth]; p3.x = m_bufDiffX[index - 1]; p3.y = m_bufDiffY[index - 1]; p4.x = m_bufDiffX[index]; p4.y = m_bufDiffY[index]; pRowStart.x = p1.x << MeshShift; pRowStart.y = p1.y << MeshShift; rowStartInc.x = p3.x - p1.x; rowStartInc.y = p3.y - p1.y; pRowEnd.x = p2.x << MeshShift; pRowEnd.y = p2.y << MeshShift; rowEndInc.x = p4.x - p2.x; rowEndInc.y = p4.y - p2.y; py = (j - 1) << MeshShift; for (int y = 0; y < MeshSize; ++y) { p.x = pRowStart.x; p.y = pRowStart.y; // scaled by MeshSize times pInc.x = (pRowEnd.x - pRowStart.x) >> MeshShift; pInc.y = (pRowEnd.y - pRowStart.y) >> MeshShift; px = (i - 1) << MeshShift; for (int x = 0; x < MeshSize; ++x) { dx = px + p.x >> MeshShift; dy = py + p.y >> MeshShift; if ((dx >= 0) && (dy >= 0) && (dx < m_width) && (dy < m_height) ) { m_bitmap2[py * m_width + px] = m_bitmap1[dy * m_width + dx]; } else { m_bitmap2[py * m_width + px] = m_bitmap1[py * m_width + px]; } p.x += pInc.x; p.y += pInc.y; ++px; } pRowStart.x += rowStartInc.x; pRowStart.y += rowStartInc.y; pRowEnd.x += rowEndInc.x; pRowEnd.y += rowEndInc.y; ++py; } } } } |