咱們知道,布丁在外力的做用下,很容易發生形變。而且,因爲布丁具備彈性,在形變以後會來回晃動。今天咱們用 Shader 來模擬布丁晃動的效果。php
老規矩,先來看一下最終效果:ios
一開始,咱們拿到的只是一張靜態的圖片。因此第一步要作的,是肯定布丁在圖片的哪一個區域。git
先來明確下思路:布丁的位置和形狀由用戶來肯定,須要在 UIKit 層完成這個交互。在肯定以後,須要把對應的位置和形狀信息傳遞給 Shader,爲後面的動畫模擬作準備。github
因爲布丁多是橢圓形或者類圓形,因此不能簡單隻用一個圓心和半徑來肯定。咱們須要一種更靈活的控制方式。bash
最終採起的方案以下:用 4 個頂點來控制 4 條貝塞爾曲線。以每條邊的中點做爲起始點和終止點,頂點做爲控制點來繪製貝塞爾曲線,4 條貝塞爾曲線造成一個封閉的類圓形。 以下圖所示:ide
儘管這樣的控制方式仍然不足以囊括全部的形狀,可是相比圓形,靈活度已經有了很大的提升。函數
另外,能夠看到中心還有一個綠色的圓點,這個也是容許用戶控制的一個維度,用來表示布丁的中心位置。主要與模擬晃動效果相關,具體有什麼用後面會說到。工具
因而,在控制層,用戶能夠經過控制 5 個點的座標,用來肯定布丁的形狀和中心。動畫
經過上個步驟,咱們拿到了位置和形狀信息。接下來則是把這些信息告訴 Shader,而後在動畫執行的時候,Shader 能夠經過計算,對目標區域內的點進行偏移處理。網站
先來看一下塞爾曲線的方程:P = (1 - t)^2 * P0 + 2 * t * (1 - t) * P1 + t^2 * P2
注:
P0
是起始點、P1
是控制點、P2
是終止點,這三點都是已知點,惟一的變量是t
,t
的取值範圍是 0 ~ 1 。
由於貝塞爾曲線有具體的方程式,因此咱們只須要傳遞關鍵點(起始點、終止點、控制點)的座標,而後在 Shader 裏去計算位置關係。
由於 UIKit 的座標和紋理座標存在差別,因此在傳遞以前有一個轉換過程,轉換代碼以下:
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
保存的是紋理座標,model
保存的是 UIKit 座標。
而傳遞仍然是用 uniform
變量的方式,咱們在以前的文章已經講過,這裏再也不贅述。
如今咱們在 Shader 中,已經能夠拿到貝塞爾曲線方程了,那麼要如何判斷點與 4 條曲線的位置關係呢?
這是本文的第一個重點。
咱們知道,在片斷着色器中,每個片斷都會執行一遍片斷着色器的代碼。因此,咱們面臨的問題是:已知一個點的紋理座標,如何判斷這個點是否在目標區域內?
先看圖,咱們根據 4 條貝塞爾曲線和中點,將目標區域劃分紅了 4 個區域。因此上面的問題能夠簡化爲:已知一個點的紋理座標,如何判斷這個點是否在單條貝塞爾曲線與中點構成的區域內?
具體的步驟以下:
經過上面的步驟,能夠判斷一個點是否在某條貝塞爾曲線的範圍內。若是不在,咱們就換另外一條曲線繼續計算。這樣,就能判斷點是否落在目標區域裏了。
如今思路已經有了,接下來就是具體的求解步驟。
咱們知道,直線方程的通常式是:Ax + By + C = 0
已知直線上的兩個點 P1(x1, y1)
、 P2(x2, y2)
,能夠求出對應的參數值:
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;
}
複製代碼
此時 A
、B
、C
能夠被當成已知數。
上面咱們已經提到過貝塞爾曲線的方程,如今將它分別拆成 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
複製代碼
將上面兩個方程代入直線方程的通常式 Ax + By + C = 0
,能夠消去 x
、y
,只剩下 t
一個未知數。
而後咱們對這個方程進行求解,得出兩個解。以下:
寫成代碼是很長的一串,這裏細節就不貼出來了,把它們封裝成兩個函數:
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;
}
複製代碼
固然,上面的解不是我本身算出來的。這裏推薦一個 工具網站 ,它能夠很快地幫咱們的方程求解。以下圖,咱們輸入消去了 x
、y
後的方程,它就幫咱們算出了兩個解:
注: 若是你去仔細閱讀源碼,會發現
getT1
、getT2
的實現與上圖的結果不是徹底一致,但其實他們在變形以後仍是等價的。這裏不用過度關注細節,只須要知道它是咱們求交點的一箇中間步驟,以及它是怎麼來的就能夠。
因而,咱們能夠經過上面的函數求出兩個 t
的值,只要 t
知足 0~1 的範圍,就說明直線和貝塞爾曲線存在交點。而後把知足條件的 t
代入貝塞爾曲線方程,就能夠算出對應的交點座標。代碼以下:
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;
}
複製代碼
求出交點以後,判斷當前點是否位於交點和中點之間,代碼以下:
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
表示點在區域內,false
則表示點在區域外。
晃動效果的實現,本質上是對目標區域內的點進行不一樣程度的位置偏移。而每一個點的位移規則,決定了最終效果的真實程度。
這是本文的第二個重點。
本來覺得,這種物理學相關的現象,應該有現成研究好的公式,我只要套下公式就行了。奈何找了一圈,啥也找不到,也多是我搜索的姿式不對,那就只好本身瞎編了。
注: 位移的規則直接決定了最終的呈現效果,我這裏只說明一下個人規則和實現方式。若是你的數學足夠好,能夠嘗試創建三維座標系,並將目標區域內的點都映射到空間中的座標,這樣能更加精確地計算出中心點位移對每一個點形成的不一樣位移影響。而我這裏只求「差強人意」便可。
個人位移規則以下:
第一點應該很好理解,這裏主要對第二點的「非線性」作一下解釋。
爲了實現咱們想要的效果,須要將目標區域近似地當成一個半球面來處理。而咱們的靜態圖片是一個俯視圖,下面用一個半圓來近似地充當一個正視圖。
這裏的 D
表示目標區域的中點,E
表示任意一個在目標區域內的點,A
表示上面提到的用 t
算出來的交點。半圓的半徑 AC
表示交點到中點的距離。
當 D
點移動到 F
點的時候,E
點會移動到 G
點,而且此時 A
點的位置不變。從俯視圖來看,D
點的移動距離是 HC
,E
點的移動距離是 IJ
。咱們的最終目的就是經過 HC
來求 IJ
。
咱們假定: AD
上全部的點,到 A
的弧長,在 D
點移動先後,所佔的弧長比例不變。即 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))
複製代碼
對應到代碼裏是這樣:
float centerOffsetAngle = acos(maxCenterDistance / maxDistance);
float currentAngle = acos(distanceToCenter / maxDistance);
float currentOffsetAngle = currentAngle * centerOffsetAngle / (PI / 2.0);
float currentOffset = maxDistance * (cos(currentOffsetAngle) - cos(currentAngle));
複製代碼
簡單來講,就是根據點到中心的距離 distanceToCenter
,來求出點的位移 currentOffset
。
因爲布丁具備彈性,在形變以後,會累積彈性勢能。因此越靠近邊緣,阻力越大。所以在中間的時候,移動速度比較快,在邊緣的時候,移動速度比較慢。
這裏用 Easeout 緩動函數來模擬這種先快後慢的效果。但遺憾的是 GLSL 中沒有提供現成的函數。
咱們來看下方程 y = 2 * x - x ^ 2
,它的圖像以下:
能夠看到,當 x
從 0 到 1 變化的時候,y
的變化速度是先快後慢。咱們正好能夠拿它來當 Easeout 緩動函數。
根據能量守恆定律,布丁在每次晃動的時候,因爲能量損耗,其具備的動能和彈性勢能會逐步衰減。換句話說,布丁每次晃動的幅度都會比上一次小。
這裏在每次晃動週期結束後,經過對振幅乘以一個縮小倍數來實現。而且,當振幅小於某個閾值的時候,直接設置爲 0
,表示回到了靜止狀態。
實際代碼以下:
model.amplitude *= 0.7;
model.amplitude = model.amplitude < 0.1 ? 0 : model.amplitude;
複製代碼
經過上面的步驟,咱們已經能夠擁有一個完整的晃動動畫了。最後一步是讓動畫響應用戶的輸入事件。
在這一步,咱們要作的是把輸入事件轉化爲一個單位方向向量,而後把這個向量傳遞給 Shader,表示晃動方向。
這裏對兩種輸入事件進行處理:屏幕觸摸,加速計。
當手指觸摸屏幕的時候,判斷觸摸點是否在目標區域的範圍內。若是在,則在手指移動的時候,根據手指的移動方向,去決定單位向量的方向。
關鍵代碼以下:
- (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;
}
}
複製代碼
這裏對加速計的詳細使用方式並不展開。咱們只須要添加一個監聽,則在手機晃動的時候,能夠在回調裏拿到加速度值的變化,從而肯定方向。
關鍵代碼以下:
self.motionManager.accelerometerUpdateInterval = 0.1; // 0.1 秒檢測一次
__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) {
// 當前的振幅小於某個閾值纔會受影響
if (model.amplitude < 0.3) {
[weakSelf startAnimationWithModel:model direction:direction amplitude:1.0];
}
}
}
}];
複製代碼
至此,咱們就獲得一個完美的布丁了。
最後,完整流程走一遍:
請到 GitHub 上查看完整代碼。
獲取更佳的閱讀體驗,請訪問原文地址【Lyman's Blog】GLSL 與布丁晃動藝術