iOS特效之你家玻璃碎了

点击获取本文示例代码

我是照骗

前言

最近逛博客看到了一篇帖子,里面介绍了自己如何设计一套星球大战主题的UI,里面有一个界面破碎的特效,看着很炫酷,那篇文章的作者使用了UIDynamics,UIKit,OpenGL分别实现了效果。于是我就寻思如何使用Metal实现这样的效果。这是那篇博客的链接。下面是Metal版本的效果预览,目前还没有和界面集成,只是在一张静态图上做的破碎效果。我增加了一些边界碰撞反弹,纯属娱乐。

代码

本文的代码在BrokenGlassEffectView文件中,它继承于MetalBaseViewMetalBaseView提供了使用Metal所需要的基础方法,BrokenGlassEffectView只需要在update和draw方法中实现逻辑刷新和绘制即可。

原理

要做这样的特效,主要分两步,切割图片,运动模拟。首先将图片切割成小方块,然后使用重力模型让小方块落下来。第一步切割可以使用两种方式:

  1. 给每个小方块创建一个四边形,并配置好UV,显示图片对应的部分。假设有n个小方块,如果使用三角形绘制,就需要6 * n个顶点。每个顶点有5个float,代表位置和uv。
  2. 每个小方块使用一个顶点绘制,绘制时使用point绘制模式,将point_size设置成小方块大小,这样只需要n个顶点。本文采用的就是这种方式,这种方式唯一的缺点是小方块只能是正方形。
    第二步就很简单了,只需要使用加速度即可。

顶点生成

我们计算出需要切割成几行几列,然后生成顶点数组。

private func buildPointData() -> [Float] {
    var vertexDataArray: [Float] = []
    let pointSize: Float = 12
    let viewWidth: Float = Float(UIScreen.main.bounds.width)
    let viewHeight: Float = Float(UIScreen.main.bounds.height)
    let rowCount = Int(viewHeight / pointSize) + 1
    let colCount = Int(viewWidth / pointSize) + 1
    let sizeXInMetalTexcoord: Float = pointSize / viewWidth * 2.0
    let sizeYInMetalTexcoord: Float = pointSize / viewHeight * 2.0
    pointTransforms = [matrix_float4x4].init()
    pointMoveInfo = [PointMoveInfo].init()
    for row in 0..<rowCount {
        for col in 0..<colCount {
            let centerX = Float(col) * sizeXInMetalTexcoord + sizeXInMetalTexcoord / 2.0 - 1.0
            let centerY = Float(row) * sizeYInMetalTexcoord + sizeYInMetalTexcoord / 2.0 - 1.0
            vertexDataArray.append(centerX)
            vertexDataArray.append(centerY)
            vertexDataArray.append(0.0)
            vertexDataArray.append(Float(col) / Float(colCount))
            vertexDataArray.append(Float(row) / Float(rowCount))
            
            pointTransforms.append(GLKMatrix4Identity.toFloat4x4())
            pointMoveInfo.append(PointMoveInfo.defaultMoveInfo(centerX: centerX, centerY: centerY))
        }
    }
    
    uniforms.pointTexcoordScaleX = sizeXInMetalTexcoord / 2.0
    uniforms.pointTexcoordScaleY = sizeYInMetalTexcoord / 2.0
    uniforms.pointSizeInPixel = pointSize
    return vertexDataArray
}

这里有一点要注意,Metal里的坐标系是x轴从-1(左)到1(右),y轴从1(上)到-1(下)。所以我生成顶点坐标时都把坐标规范到了-1到1这个范围。 这里除了生成顶点,还计算了点纹理坐标需要的缩放量pointTexcoordScaleX,pointTexcoordScaleY,并且把点的像素大小传递给Uniforms。这个Uniforms会在后面传递给Shader。关于点纹理坐标需要的缩放量我会在后面介绍它的作用。pointTransformspointMoveInfo保存了每个点的运动信息,这里对他们进行了初始化。
然后我们在setupRenderAssets中初始化顶点Buffer。

// 构建顶点
self.vertexArray = buildPointData()
let vertexBufferSize = MemoryLayout<Float>.size * self.vertexArray.count
self.vertexBuffer = device.makeBuffer(bytes: self.vertexArray, length: vertexBufferSize, options: MTLResourceOptions.cpuCacheModeWriteCombined)

更新运动信息

下面我们在update方法中更新运动信息。每个点都有以下运动信息。x,y轴的速度,x,y轴的加速度,点最初的中心位置originCenterX,originCenterY,点的位移translateX,translateY。

struct PointMoveInfo {
    var xSpeed: Float
    var ySpeed: Float
    var xAccelerate: Float
    var yAccelerate: Float
    var originCenterX: Float
    var originCenterY: Float
    var translateX: Float
    var translateY: Float
  
    ...
}

我们使用这些信息就可以对点进行运动模拟。首先我们处理y轴上的速度,每次update,速度会随着加速度改变,如果超过了最大速度,那么就等于最大速度,因为我这里的速度是负的,所以用的是小于。所以准确来说应该是速度的绝对值超过了最大速度的绝对值。

pointMoveInfo[i].ySpeed += Float(deltaTime) * pointMoveInfo[i].yAccelerate
if pointMoveInfo[i].ySpeed < maxYSpeed {
    pointMoveInfo[i].ySpeed = maxYSpeed
}

然后是位移。并且用位移数据生成Shader使用的矩阵。

pointMoveInfo[i].translateX += Float(deltaTime) * pointMoveInfo[i].xSpeed
pointMoveInfo[i].translateY += Float(deltaTime) * pointMoveInfo[i].ySpeed
let newMatrix = GLKMatrix4MakeTranslation(pointMoveInfo[i].translateX, pointMoveInfo[i].translateY, 0)
pointTransforms[i] = newMatrix.toFloat4x4()

最后我做了边界检测,遇到边界则反弹并且有衰减。

let realY = pointMoveInfo[i].translateY + pointMoveInfo[i].originCenterY
let realX = pointMoveInfo[i].translateX + pointMoveInfo[i].originCenterX
if realY <= -1.0 {
    pointMoveInfo[i].ySpeed = -pointMoveInfo[i].ySpeed * 0.6
    if fabs(pointMoveInfo[i].ySpeed) < 0.01 {
        pointMoveInfo[i].ySpeed = 0
    }
}
if realX <= -1.0 || realX >= 1.0 {
    pointMoveInfo[i].xSpeed = -pointMoveInfo[i].xSpeed * 0.6
    if fabs(pointMoveInfo[i].xSpeed) < 0.01 {
        pointMoveInfo[i].xSpeed = 0
    }
}

渲染

顶点和运动信息万事具备,可以渲染了。我们把顶点Buffer,纹理,Uniforms,运动信息pointTransforms都传递给Shader,接下来就轮到Shader表演了。

override func draw(renderEncoder: MTLRenderCommandEncoder) {
    renderEncoder.setVertexBuffer(self.vertexBuffer, offset: 0, index: 0)
    renderEncoder.setFragmentTexture(self.imageTexture, index: 0)
    
    let uniformBuffer = device.makeBuffer(bytes: self.uniforms.data(), length: Uniforms.sizeInBytes(), options:
MTLResourceOptions.cpuCacheModeWriteCombined)
    renderEncoder.setVertexBuffer(uniformBuffer, offset: 0, index: 1)
    renderEncoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 0)
    
    let transformsBufferSize = MemoryLayout<matrix_float4x4>.size * pointTransforms.count
    let transformsBuffer = device.makeBuffer(bytes: pointTransforms, length: transformsBufferSize, options:
MTLResourceOptions.cpuCacheModeWriteCombined)
    renderEncoder.setVertexBuffer(transformsBuffer, offset: 0, index: 2)
    
    renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: self.vertexArray.count / 5)
}

Shader

我们先来看看Shader中定义的结构体。输入的顶点VertexIn中包含位置和点所在位置的信息,点所在位置已经被规范化到0到1的区间了。输出到Fragment Shader的VertexOut结构包含处理后的位置,点所在位置的信息和点的像素尺寸。Uniforms里包含点纹理坐标的缩放量以及点的像素大小。

struct VertexIn
{
    packed_float3  position;
    packed_float2  pointPosition;
};

struct VertexOut
{
    float4  position [[position]];
    float2  pointPosition;
    float pointSize [[ point_size ]];
};

struct Uniforms
{
    packed_float2 pointTexcoordScale;
    float pointSizeInPixel;
};

接下来我们看看Vertex Shader。主要做了三件事情。

  1. 将输入的位置信息使用运动信息transform进行变换。
  2. 把点规范化后的位置信息原封不动的传给Fargment Shader。
  3. 把点的像素大小传递给point_size。
vertex VertexOut passThroughVertex(uint vid [[ vertex_id ]],
                                   const device VertexIn* vertexIn [[ buffer(0) ]],
                                   const device Uniforms& uniforms [[ buffer(1) ]],
                                   const device float4x4* transforms [[ buffer(2) ]])
{
    VertexOut outVertex;
    VertexIn inVertex = vertexIn[vid];
    outVertex.position = transforms[vid] * float4(inVertex.position, 1.0);
    outVertex.pointPosition = inVertex.pointPosition;
    outVertex.pointSize = uniforms.pointSizeInPixel;
    return outVertex;
};

最后轮到我们的Fragment Shader登场了。这里的核心就是计算UV,将点纹理坐标pointCoord在y轴上翻转后乘以点纹理缩放量求解出额外的UV偏移。然后以点的位置信息为基础UV,两者相加。最后将相加后的UV在Y轴上翻转就得到可以使用的UV了。从diffuse纹理上采样,然后返回采样到的颜色。

constexpr sampler s(coord::normalized, address::repeat, filter::linear);

fragment float4 passThroughFragment(VertexOut inFrag [[stage_in]],
                                    float2 pointCoord  [[point_coord]],
                                     texture2d<float> diffuse [[ texture(0) ]],
                                    const device Uniforms& uniforms [[ buffer(0) ]])
{
    float2 additionUV = float2((pointCoord[0]) * uniforms.pointTexcoordScale[0], (1.0 - pointCoord[1]) * uniforms.pointTexcoordScale[1]);
    float2 uv = inFrag.pointPosition + additionUV;
    uv = float2(uv[0], 1.0 - uv[1]);
    float4 finalColor = diffuse.sample(s, uv);
    return finalColor;
};

到此,Shader就介绍完了,还是很简单的,代码量并不大。主要流程就是VertexShader处理运动信息,FragmentShader处理图片在点上的着色。

总结

本文使用的方法类似于一个小型的粒子系统,使用点精灵(Point Sprites)技术比较高效的实现了碎片的效果。我们可以在update中使用其他的运动模拟算法实现类似于爆炸,旋涡等效果,如果读者有兴趣,可以自己尝试一下。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容