咱们知道,布丁在外力的做用下,很容易发生形变。而且,因为布丁具备弹性,在形变以后会来回晃动。今天咱们用 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 与布丁晃动艺术