iOS特效之你家玻璃碎了

點擊獲取本文示例代碼

我是照騙

前言

最近逛博客看到了一篇帖子,里面介紹了自己如何設計一套星球大戰(zhàn)主題的UI,里面有一個界面破碎的特效,看著很炫酷,那篇文章的作者使用了UIDynamics,UIKit,OpenGL分別實現(xiàn)了效果。于是我就尋思如何使用Metal實現(xiàn)這樣的效果。這是那篇博客的鏈接。下面是Metal版本的效果預覽,目前還沒有和界面集成,只是在一張靜態(tài)圖上做的破碎效果。我增加了一些邊界碰撞反彈,純屬娛樂。

代碼

本文的代碼在BrokenGlassEffectView文件中,它繼承于MetalBaseView,MetalBaseView提供了使用Metal所需要的基礎方法,BrokenGlassEffectView只需要在update和draw方法中實現(xiàn)邏輯刷新和繪制即可。

原理

要做這樣的特效,主要分兩步,切割圖片,運動模擬。首先將圖片切割成小方塊,然后使用重力模型讓小方塊落下來。第一步切割可以使用兩種方式:

  1. 給每個小方塊創(chuàng)建一個四邊形,并配置好UV,顯示圖片對應的部分。假設有n個小方塊,如果使用三角形繪制,就需要6 * n個頂點。每個頂點有5個float,代表位置和uv。
  2. 每個小方塊使用一個頂點繪制,繪制時使用point繪制模式,將point_size設置成小方塊大小,這樣只需要n個頂點。本文采用的就是這種方式,這種方式唯一的缺點是小方塊只能是正方形。
    第二步就很簡單了,只需要使用加速度即可。

頂點生成

我們計算出需要切割成幾行幾列,然后生成頂點數(shù)組。

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(下)。所以我生成頂點坐標時都把坐標規(guī)范到了-1到1這個范圍。 這里除了生成頂點,還計算了點紋理坐標需要的縮放量pointTexcoordScaleX,pointTexcoordScaleY,并且把點的像素大小傳遞給Uniforms。這個Uniforms會在后面?zhèn)鬟f給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
}

然后是位移。并且用位移數(shù)據(jù)生成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中包含位置和點所在位置的信息,點所在位置已經(jīng)被規(guī)范化到0到1的區(qū)間了。輸出到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. 把點規(guī)范化后的位置信息原封不動的傳給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處理運動信息,F(xiàn)ragmentShader處理圖片在點上的著色。

總結

本文使用的方法類似于一個小型的粒子系統(tǒng),使用點精靈(Point Sprites)技術比較高效的實現(xiàn)了碎片的效果。我們可以在update中使用其他的運動模擬算法實現(xiàn)類似于爆炸,旋渦等效果,如果讀者有興趣,可以自己嘗試一下。

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容