本文所用的代码在https://github.com/SquarePants1991/WaveEffect
工作需要,最近要实现一个波浪效果,一般的做法是使用UIBezierPath生成sin曲线,通过CADisplayLink刷新曲线的相位或者幅度来达到波浪效果。本文要介绍另外一种方式,使用OpenGL来实现波浪效果。下面是效果图,上面使用的是GLKView,下面是CAShapeLayer。这两种方式我都做了简单的遮罩效果。
接下来就重点介绍如何使用OpenGL实现这样的效果。GLWaveView
和GLContext
里包含了所有的实现代码。fragment.glsl
和vertex.glsl
两个着色器是整个效果的核心。先来看看GLWaveView
的代码吧。
GLWaveView
GLWaveView
继承自GLKView
,所以可以很方便的初始化OpenGL相关环境。
static EAGLContext *eaglContext;
if (eaglContext == nil) {
eaglContext = [[EAGLContext alloc] initWithAPI: kEAGLRenderingAPIOpenGLES2];
[EAGLContext setCurrentContext:eaglContext];
}
self.context = eaglContext;
self.drawableMultisample = GLKViewDrawableMultisample4X;
因为EAGLContext
当前线程只能设置一个,所以使用静态变量来表示,接着为GLWaveView
设置好EAGLContext
和多重采样率GLKViewDrawableMultisample4X
,多重采样可以让锯齿更平滑。为了实现动画,需要一个循环来运行渲染代码,这里使用的是CADisplayLink
。
CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(ticked)];
displayLink.preferredFramesPerSecond = 60;
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
lastTime = [[NSDate date] timeIntervalSince1970];
- (void)ticked {
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
currentTime += now - lastTime;
lastTime = now;
[self display];
}
可以为CADisplayLink
指定需要的帧率preferredFramesPerSecond
,这里我设定的是60fps。lastTime
用来保存上一次更新的时间戳,ticked
会被循环调用,每调用一次,都会计算当前经过的总时长currentTime
。[self display];
调用后会触发重绘。执行下面的代码。下面的代码主要就是绘制了一个撑满当前View的四边形,并且绑定了一张图片到diffuseMap
。这个会在Shader中用来当遮罩图。
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
[self.glContext active];
glClearColor(0.0, 0.0, 0.0, 0.0);
glClear(GL_COLOR_BUFFER_BIT);
static GLfloat triangleData[] = {
-1, 1, 0.5, 0, 0, 1, 0, 0,
-1, -1, 0.5, 0, 0, 1, 0, 1,
1, -1, 0.5, 0, 0, 1, 1, 1,
1, -1, 0.5, 0, 0, 1, 1, 1,
1, 1, 0.5, 0, 0, 1, 1, 0,
-1, 1, 0.5, 0, 0, 1, 0, 0,
};
[self.glContext setUniform1f:@"time" value:currentTime];
[self.glContext bindTexture:self.diffuseMap to:GL_TEXTURE0 uniformName:@"diffuseMap"];
[self.glContext drawTriangles:triangleData vertexCount:6];
}
在此之前,你还需要设置
self.delegate = self;
,
- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
是
GLKView
的delegate中的一个方法。
同时初始化OpenGL的工具类GLContext也是必要的。综合起来初始化代码如下。
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
static EAGLContext *eaglContext;
if (eaglContext == nil) {
eaglContext = [[EAGLContext alloc] initWithAPI: kEAGLRenderingAPIOpenGLES2];
[EAGLContext setCurrentContext:eaglContext];
}
self.context = eaglContext;
self.drawableMultisample = GLKViewDrawableMultisample4X;
self.delegate = self;
self.layer.backgroundColor = [UIColor clearColor].CGColor;
self.layer.opaque = NO;
NSString *vertexShader = [[NSBundle mainBundle] pathForResource:@"vertex" ofType:@"glsl"];
NSString *fragmentShader = [[NSBundle mainBundle] pathForResource:@"fragment" ofType:@"glsl"];
NSString *vertexShaderContent = [NSString stringWithContentsOfFile:vertexShader encoding:NSUTF8StringEncoding error:nil];
NSString *fragmentShaderContent = [NSString stringWithContentsOfFile:fragmentShader encoding:NSUTF8StringEncoding error:nil];
self.glContext = [[GLContext alloc] initWithVertexShader:vertexShaderContent fragmentShader:fragmentShaderContent];
self.diffuseMap = [GLKTextureLoader textureWithCGImage:[UIImage imageNamed:@"mask.png"].CGImage options:nil error:nil];
CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(ticked)];
displayLink.preferredFramesPerSecond = 60;
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
lastTime = [[NSDate date] timeIntervalSince1970];
}
return self;
}
如果你对OpenGL不熟悉,可以去阅读学习OpenGL ES系列文章,GLContext这个类是从那边直接拿过来的。主要封装了OpenGL的一些基本操作。
有了上面这些,就可以使用OpenGL绘制一个四边形了。接下来就该Fragment Shader出场了。
Fragment Shader
先来说一下思路,用uv当做坐标值,计算当前uv.x对应的正弦值sin(uv.x),如果uv.y在sin(uv.x)之下,就给gl_FragColor
着色。这样就能绘制出一个基本的波浪。判断当前点是否着色的代码如下。
bool shouldBeColored(float waveAmplitude, float waveHeight, float phase, float period) {
float x = fragUV.x * period; // x轴范围0~period
float y = 1.0 - fragUV.y;
float topY = (sin(x + phase) + 1.0) / 2.0 * waveAmplitude - waveAmplitude / 2.0 + waveHeight;
return y <= topY;
}
waveAmplitude
是波峰和波谷的间距,waveHeight
是波峰的位置,phase
是计算正弦的初始相位,period
是可见范围的周期。下面是示意图。因为uv的y是从0到1的,所以先进行翻转float y = 1.0 - fragUV.y;
,最后如果翻转后的y在topY之下即可着色。
下面是完整代码。
precision highp float;
varying vec3 fragNormal;
varying vec2 fragUV;
uniform sampler2D diffuseMap;
uniform float time;
bool shouldBeColored(float waveAmplitude, float waveHeight, float phase, float period) {
float x = fragUV.x * period; // x轴范围0~period
float y = 1.0 - fragUV.y;
float topY = (sin(x + phase) + 1.0) / 2.0 * waveAmplitude - waveAmplitude / 2.0 + waveHeight;
return y <= topY;
}
void main(void) {
vec4 color = texture2D(diffuseMap, fragUV);
float baseFactor = (sin(time / 4.5) + 1.0) / 2.0;
float heightFactor = baseFactor;
float phaseFactor = time * 3.14;
float period = 3.14 * 1.4; // 周期
vec4 finalColor = vec4(0.2, 0.2, 0.2, 1.0);
if (shouldBeColored(0.07, heightFactor, phaseFactor, period)) {
finalColor = finalColor * 0.0 + vec4(1.0, 0.4, 0.4, 1.0);
}
if (shouldBeColored(0.05, heightFactor - 0.02, phaseFactor - 0.25, period)) {
finalColor = finalColor * 0.4 + vec4(1.0, 0.1, 0.1, 1.0) * 0.6;
}
gl_FragColor = finalColor * color.a;
}
上面一共绘制了两个波浪,第二个波浪赋予了0.6的Alpha值,采用SrcColor * SrcAlpha + DstColor * (1 - SrcAlpha)的混合算法。两个波浪的有0.02的振幅差,和0.02的高度差,相位差0.25。高度和相位都会随着时间改变而改变。周期是1.4Pi。
最后finalColor * color.a;
将会使遮罩图上alpha为0的部分不可见,从而达到遮罩的效果。
GitHub上的代码中也包含UIBezierPath
实现的版本CAWaveView
,有兴趣的可以自己clone查看。