
我們知道,布丁在外力的作用下,很容易發(fā)生形變。并且,由于布丁具有彈性,在形變之后會(huì)來回晃動(dòng)。今天我們用 Shader 來模擬布丁晃動(dòng)的效果。
老規(guī)矩,先來看一下最終效果:

一、位置和形狀
1、控制層
一開始,我們拿到的只是一張靜態(tài)的圖片。所以第一步要做的,是確定布丁在圖片的哪個(gè)區(qū)域。
先來明確下思路:布丁的位置和形狀由用戶來確定,需要在 UIKit 層完成這個(gè)交互。在確定之后,需要把對(duì)應(yīng)的位置和形狀信息傳遞給 Shader,為后面的動(dòng)畫模擬做準(zhǔn)備。
由于布丁可能是橢圓形或者類圓形,所以不能簡(jiǎn)單只用一個(gè)圓心和半徑來確定。我們需要一種更靈活的控制方式。
最終采取的方案如下:用 4 個(gè)頂點(diǎn)來控制 4 條貝塞爾曲線。以每條邊的中點(diǎn)作為起始點(diǎn)和終止點(diǎn),頂點(diǎn)作為控制點(diǎn)來繪制貝塞爾曲線,4 條貝塞爾曲線形成一個(gè)封閉的類圓形。如下圖所示:

盡管這樣的控制方式仍然不足以囊括所有的形狀,但是相比圓形,靈活度已經(jīng)有了很大的提高。
另外,可以看到中心還有一個(gè)綠色的圓點(diǎn),這個(gè)也是允許用戶控制的一個(gè)維度,用來表示布丁的中心位置。主要與模擬晃動(dòng)效果相關(guān),具體有什么用后面會(huì)說到。
于是,在控制層,用戶可以通過控制 5 個(gè)點(diǎn)的坐標(biāo),用來確定布丁的形狀和中心。
2、數(shù)據(jù)傳遞
通過上個(gè)步驟,我們拿到了位置和形狀信息。接下來則是把這些信息告訴 Shader,然后在動(dòng)畫執(zhí)行的時(shí)候,Shader 可以通過計(jì)算,對(duì)目標(biāo)區(qū)域內(nèi)的點(diǎn)進(jìn)行偏移處理。
先來看一下塞爾曲線的方程:P = (1 - t)^2 * P0 + 2 * t * (1 - t) * P1 + t^2 * P2
注:
P0是起始點(diǎn)、P1是控制點(diǎn)、P2是終止點(diǎn),這三點(diǎn)都是已知點(diǎn),唯一的變量是t,t的取值范圍是 0 ~ 1 。
因?yàn)樨惾麪柷€有具體的方程式,所以我們只需要傳遞關(guān)鍵點(diǎn)(起始點(diǎn)、終止點(diǎn)、控制點(diǎn))的坐標(biāo),然后在 Shader 里去計(jì)算位置關(guān)系。
因?yàn)?UIKit 的坐標(biāo)和紋理坐標(biāo)存在差異,所以在傳遞之前有一個(gè)轉(zhuǎn)換過程,轉(zhuǎn)換代碼如下:
MFWobbleModel *wobbleModel = [[MFWobbleModel alloc] init];
wobbleModel.pointLT = CGPointMake(model.pointLT.x / width, 1 - (model.pointLT.y / height));
wobbleModel.pointRT = CGPointMake(model.pointRT.x / width, 1 - (model.pointRT.y / height));
wobbleModel.pointRB = CGPointMake(model.pointRB.x / width, 1 - (model.pointRB.y / height));
wobbleModel.pointLB = CGPointMake(model.pointLB.x / width, 1 - (model.pointLB.y / height));
wobbleModel.center = CGPointMake(model.center.x / width, 1 - (model.center.y / height));
注:
wobbleModel保存的是紋理坐標(biāo),model保存的是 UIKit 坐標(biāo)。
而傳遞仍然是用 uniform 變量的方式,我們?cè)谥暗奈恼乱呀?jīng)講過,這里不再贅述。
現(xiàn)在我們?cè)?Shader 中,已經(jīng)可以拿到貝塞爾曲線方程了,那么要如何判斷點(diǎn)與 4 條曲線的位置關(guān)系呢?
這是本文的第一個(gè)重點(diǎn)。
我們知道,在片段著色器中,每一個(gè)片段都會(huì)執(zhí)行一遍片段著色器的代碼。所以,我們面臨的問題是:已知一個(gè)點(diǎn)的紋理坐標(biāo),如何判斷這個(gè)點(diǎn)是否在目標(biāo)區(qū)域內(nèi)?
先看圖,我們根據(jù) 4 條貝塞爾曲線和中點(diǎn),將目標(biāo)區(qū)域劃分成了 4 個(gè)區(qū)域。所以上面的問題可以簡(jiǎn)化為:已知一個(gè)點(diǎn)的紋理坐標(biāo),如何判斷這個(gè)點(diǎn)是否在單條貝塞爾曲線與中點(diǎn)構(gòu)成的區(qū)域內(nèi)?

具體的步驟如下:
1、將當(dāng)前點(diǎn)與中點(diǎn)進(jìn)行連接得到一條直線,求出直線方程。
2、求直線和貝塞爾曲線的交點(diǎn)。
3、如果有交點(diǎn),判讀當(dāng)前點(diǎn)是否位于交點(diǎn)和中心點(diǎn)之間,在就說明點(diǎn)在區(qū)域內(nèi),否則就在區(qū)域外。
通過上面的步驟,可以判斷一個(gè)點(diǎn)是否在某條貝塞爾曲線的范圍內(nèi)。如果不在,我們就換另一條曲線繼續(xù)計(jì)算。這樣,就能判斷點(diǎn)是否落在目標(biāo)區(qū)域里了。
現(xiàn)在思路已經(jīng)有了,接下來就是具體的求解步驟。
我們知道,直線方程的一般式是:Ax + By + C = 0
已知直線上的兩個(gè)點(diǎn) P1(x1, y1)、 P2(x2, y2) ,可以求出對(duì)應(yīng)的參數(shù)值:
A = y2 - y1
B = x1 - x2
C = x2 * y1 - x1 * y2
寫成代碼是:
float getA(vec2 point1, vec2 point2) {
return point2.y - point1.y;
}
float getB(vec2 point1, vec2 point2) {
return point1.x - point2.x;
}
float getC(vec2 point1, vec2 point2) {
return point2.x * point1.y - point1.x * point2.y;
}
此時(shí) A 、B 、C 可以被當(dāng)成已知數(shù)。
上面我們已經(jīng)提到過貝塞爾曲線的方程,現(xiàn)在將它分別拆成 x 、y 的方程。
x = (1 - t)^2 * x0 + 2 * t * (1 - t) * x1 + t^2 * x2
y = (1 - t)^2 * y0 + 2 * t * (1 - t) * y1 + t^2 * y2
將上面兩個(gè)方程代入直線方程的一般式 Ax + By + C = 0,可以消去 x 、y,只剩下 t 一個(gè)未知數(shù)。
然后我們對(duì)這個(gè)方程進(jìn)行求解,得出兩個(gè)解。如下:

寫成代碼是很長(zhǎng)的一串,這里細(xì)節(jié)就不貼出來了,把它們封裝成兩個(gè)函數(shù):
float getT1(vec2 point1, vec2 point2, vec2 point3, float a, float b, float c) {
float t; // t = ...
return t;
}
float getT2(vec2 point1, vec2 point2, vec2 point3, float a, float b, float c) {
float t; // t = ...
return t;
}
當(dāng)然,上面的解不是我自己算出來的。這里推薦一個(gè) 工具網(wǎng)站 ,它可以很快地幫我們的方程求解。如下圖,我們輸入消去了 x 、y 后的方程,它就幫我們算出了兩個(gè)解:

注: 如果你去仔細(xì)閱讀源碼,會(huì)發(fā)現(xiàn)
getT1、getT2的實(shí)現(xiàn)與上圖的結(jié)果不是完全一致,但其實(shí)他們?cè)谧冃沃筮€是等價(jià)的。這里不用過分關(guān)注細(xì)節(jié),只需要知道它是我們求交點(diǎn)的一個(gè)中間步驟,以及它是怎么來的就可以。
于是,我們可以通過上面的函數(shù)求出兩個(gè) t 的值,只要 t 滿足 0~1 的范圍,就說明直線和貝塞爾曲線存在交點(diǎn)。然后把滿足條件的 t 代入貝塞爾曲線方程,就可以算出對(duì)應(yīng)的交點(diǎn)坐標(biāo)。代碼如下:
vec2 getPoint(vec2 point1, vec2 point2, vec2 point3, float t) {
vec2 point = pow(1.0 - t, 2.0) * point1 + 2.0 * t * (1.0 - t) * point2 + pow(t, 2.0) * point3;
return point;
}
求出交點(diǎn)之后,判斷當(dāng)前點(diǎn)是否位于交點(diǎn)和中點(diǎn)之間,代碼如下:
bool isPointInside(vec2 point, vec2 point1, vec2 point2) {
vec2 tmp1 = point - point1;
vec2 tmp2 = point - point2;
return tmp1.x * tmp2.x <= 0.0 && tmp1.y * tmp2.y <= 0.0;
}
這里返回 true 表示點(diǎn)在區(qū)域內(nèi),false 則表示點(diǎn)在區(qū)域外。
二、物理效果模擬
1、位置偏移
晃動(dòng)效果的實(shí)現(xiàn),本質(zhì)上是對(duì)目標(biāo)區(qū)域內(nèi)的點(diǎn)進(jìn)行不同程度的位置偏移。而每個(gè)點(diǎn)的位移規(guī)則,決定了最終效果的真實(shí)程度。
這是本文的第二個(gè)重點(diǎn)。
原本以為,這種物理學(xué)相關(guān)的現(xiàn)象,應(yīng)該有現(xiàn)成研究好的公式,我只要套下公式就好了。奈何找了一圈,啥也找不到,也可能是我搜索的姿勢(shì)不對(duì),那就只好自己瞎編了。
注: 位移的規(guī)則直接決定了最終的呈現(xiàn)效果,我這里只說明一下我的規(guī)則和實(shí)現(xiàn)方式。如果你的數(shù)學(xué)足夠好,可以嘗試建立三維坐標(biāo)系,并將目標(biāo)區(qū)域內(nèi)的點(diǎn)都映射到空間中的坐標(biāo),這樣能更加精確地計(jì)算出中心點(diǎn)位移對(duì)每個(gè)點(diǎn)造成的不同位移影響。而我這里只求「差強(qiáng)人意」即可。
我的位移規(guī)則如下:
1、位移只跟當(dāng)前點(diǎn)與中心點(diǎn)的距離有關(guān)。距離越大,位移越小,區(qū)域邊緣的位移為 0。
2、隨著與中心點(diǎn)距離的增加,位移呈非線形遞減。
第一點(diǎn)應(yīng)該很好理解,這里主要對(duì)第二點(diǎn)的「非線性」做一下解釋。
為了實(shí)現(xiàn)我們想要的效果,需要將目標(biāo)區(qū)域近似地當(dāng)成一個(gè)半球面來處理。而我們的靜態(tài)圖片是一個(gè)俯視圖,下面用一個(gè)半圓來近似地充當(dāng)一個(gè)正視圖。

這里的 D 表示目標(biāo)區(qū)域的中點(diǎn),E 表示任意一個(gè)在目標(biāo)區(qū)域內(nèi)的點(diǎn),A 表示上面提到的用 t 算出來的交點(diǎn)。半圓的半徑 AC 表示交點(diǎn)到中點(diǎn)的距離。
當(dāng) D 點(diǎn)移動(dòng)到 F 點(diǎn)的時(shí)候,E 點(diǎn)會(huì)移動(dòng)到 G 點(diǎn),并且此時(shí) A 點(diǎn)的位置不變。從俯視圖來看,D 點(diǎn)的移動(dòng)距離是 HC ,E 點(diǎn)的移動(dòng)距離是 IJ 。我們的最終目的就是通過 HC 來求 IJ 。
我們假定: AD 上所有的點(diǎn),到 A 的弧長(zhǎng),在 D 點(diǎn)移動(dòng)前后,所占的弧長(zhǎng)比例不變。即 AG / AE = AF / AD 。
所以 IJ 的求解步驟是:
AF = acos(HC / AC) * AC
AE = acos(JC / AC) * AC
AD = (PI / 2) * AC
AG = AE * AF / AD
IJ = AC * (cos(AG / AC) - cos(AE / AC))
對(duì)應(yīng)到代碼里是這樣:
float centerOffsetAngle = acos(maxCenterDistance / maxDistance);
float currentAngle = acos(distanceToCenter / maxDistance);
float currentOffsetAngle = currentAngle * centerOffsetAngle / (PI / 2.0);
float currentOffset = maxDistance * (cos(currentOffsetAngle) - cos(currentAngle));
簡(jiǎn)單來說,就是根據(jù)點(diǎn)到中心的距離 distanceToCenter ,來求出點(diǎn)的位移 currentOffset 。
2、阻力模擬
由于布丁具有彈性,在形變之后,會(huì)累積彈性勢(shì)能。所以越靠近邊緣,阻力越大。因此在中間的時(shí)候,移動(dòng)速度比較快,在邊緣的時(shí)候,移動(dòng)速度比較慢。
這里用 Easeout 緩動(dòng)函數(shù)來模擬這種先快后慢的效果。但遺憾的是 GLSL 中沒有提供現(xiàn)成的函數(shù)。
我們來看下方程 y = 2 * x - x ^ 2,它的圖像如下:

可以看到,當(dāng) x 從 0 到 1 變化的時(shí)候,y 的變化速度是先快后慢。我們正好可以拿它來當(dāng) Easeout 緩動(dòng)函數(shù)。
3、振幅衰減
根據(jù)能量守恒定律,布丁在每次晃動(dòng)的時(shí)候,由于能量損耗,其具有的動(dòng)能和彈性勢(shì)能會(huì)逐步衰減。換句話說,布丁每次晃動(dòng)的幅度都會(huì)比上一次小。
這里在每次晃動(dòng)周期結(jié)束后,通過對(duì)振幅乘以一個(gè)縮小倍數(shù)來實(shí)現(xiàn)。并且,當(dāng)振幅小于某個(gè)閾值的時(shí)候,直接設(shè)置為 0 ,表示回到了靜止?fàn)顟B(tài)。
實(shí)際代碼如下:
model.amplitude *= 0.7;
model.amplitude = model.amplitude < 0.1 ? 0 : model.amplitude;
三、輸入事件處理
通過上面的步驟,我們已經(jīng)可以擁有一個(gè)完整的晃動(dòng)動(dòng)畫了。最后一步是讓動(dòng)畫響應(yīng)用戶的輸入事件。
在這一步,我們要做的是把輸入事件轉(zhuǎn)化為一個(gè)單位方向向量,然后把這個(gè)向量傳遞給 Shader,表示晃動(dòng)方向。
這里對(duì)兩種輸入事件進(jìn)行處理:屏幕觸摸,加速計(jì)。
1、觸摸事件
當(dāng)手指觸摸屏幕的時(shí)候,判斷觸摸點(diǎn)是否在目標(biāo)區(qū)域的范圍內(nèi)。如果在,則在手指移動(dòng)的時(shí)候,根據(jù)手指的移動(dòng)方向,去決定單位向量的方向。
關(guān)鍵代碼如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[super touchesBegan:touches withEvent:event];
CGPoint currentPoint = [[touches anyObject] locationInView:self];
currentPoint = CGPointMake(currentPoint.x / self.bounds.size.width, 1 - (currentPoint.y / self.bounds.size.height)); // 歸一化
for (MFWobbleModel *model in self.wobbleModels) {
if ([model containsPoint:currentPoint]) {
self.currentTouchModel = model;
self.startPoint = currentPoint;
break;
}
}
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[super touchesMoved:touches withEvent:event];
if (self.currentTouchModel) {
CGPoint currentPoint = [[touches anyObject] locationInView:self];
currentPoint = CGPointMake(currentPoint.x / self.bounds.size.width, 1 - (currentPoint.y / self.bounds.size.height)); // 歸一化
CGFloat distance = sqrt(pow(self.startPoint.x - currentPoint.x, 2.0) + pow(self.startPoint.y - currentPoint.y, 2.0));
CGPoint direction = CGPointMake((currentPoint.x - self.startPoint.x) / distance, ((currentPoint.y - self.startPoint.y) / distance));
[self startAnimationWithModel:self.currentTouchModel direction:direction amplitude:1.0];
self.currentTouchModel = nil;
}
}
2、加速計(jì)
這里對(duì)加速計(jì)的詳細(xì)使用方式并不展開。我們只需要添加一個(gè)監(jiān)聽,則在手機(jī)晃動(dòng)的時(shí)候,可以在回調(diào)里拿到加速度值的變化,從而確定方向。
關(guān)鍵代碼如下:
self.motionManager.accelerometerUpdateInterval = 0.1; // 0.1 秒檢測(cè)一次
__weak typeof(self) weakSelf = self;
[self.motionManager startAccelerometerUpdatesToQueue:[[NSOperationQueue alloc] init] withHandler:^(CMAccelerometerData *accelerometerData, NSError *error) {
CMAcceleration acceleration = accelerometerData.acceleration;
CGFloat sensitivity = sqrt(pow(acceleration.x, 2.0) + pow(acceleration.y, 2.0));
if (sensitivity > 1.0) {
CGPoint direction = CGPointMake(acceleration.x / sensitivity, acceleration.y / sensitivity);
for (MFWobbleModel *model in weakSelf.wobbleModels) {
// 當(dāng)前的振幅小于某個(gè)閾值才會(huì)受影響
if (model.amplitude < 0.3) {
[weakSelf startAnimationWithModel:model direction:direction amplitude:1.0];
}
}
}
}];
至此,我們就得到一個(gè)完美的布丁了。
最后,完整流程走一遍:

源碼
請(qǐng)到 GitHub 上查看完整代碼。
參考
獲取更佳的閱讀體驗(yàn),請(qǐng)?jiān)L問原文地址【Lyman's Blog】GLSL 與布丁晃動(dòng)藝術(shù)