Swift 程式語言

如何使用 CATransform3D 處理 3D 影像、製做互動立體旋轉的效果

如何使用 CATransform3D 處理 3D 影像、製做互動立體旋轉的效果
如何使用 CATransform3D 處理 3D 影像、製做互動立體旋轉的效果
In: Swift 程式語言

今天讓我們討論一下在iOS App的立體影像處理,而要講這題目就不可不講解 – CATransform3DCATransform3D 是一個用來處理 3D 影像,像是旋轉、縮放、平移等 3D 影像的控制。CATransform3D 是採用三維坐標系統,x 軸向下為正,y 向右為正,z 軸則是垂直於螢幕向外為正。

你可以這樣理解CATransform3D ,它本身就是一個 4×4 的矩陣:

Matrix

在這邊,我們不需要了解矩陣中每一個數字是什麼意思,因為在 CATransform3D 中已經有方法可以處理大部分的功能:

  • CATransform3DTranslate
  • CATransform3DRotate
  • CATransform3DInvert
  • CATransform3DScale
  • CATransform3DAffine

如果只說道理,相信很難理解CATransform3D的運作,最好還是實作一個簡單的範例程式以闡釋當中的原理。在這篇,我要教大家如何做出一個可以被轉動的骰子,下圖就是完成品:

Dice

開始建立範例App

如常開啟Xcode並使用Single View Application建立一個新項目。在Main.storyboard,先在View Controller 做一個簡單的 View,將背景顏色轉為藍色(或其他顏色也可以)。

rotatedice-storyboard

ViewController 加入IBOutlet,並將之連接至新建立的View:

@IBOutlet weak var blueView: UIView!

如何將View變得立體

現在要加入一個方法讓 View (即blueView) 以 y 軸為中心旋轉 45度,CATransform3D 的方法都是輸入一個矩陣去改變內容。以 CATransform3DRotate 為例,第一個參數是定義好的矩陣,之後再用角度與 x、y、z 所形成的向量做旋轉:

func viewTransform() {
    var transform = CATransform3DIdentity
    let angle = CGFloat(45)
    blueView.layer.transform = CATransform3DRotate(transform, angle, 0, 1, 0)
}

之後,再在viewDidLoad()方法呼叫viewTransform()

override func viewDidLoad() {
    super.viewDidLoad()
    
    viewTransform()
}

現在可先試試執行程式,你會發覺那個View實際上旋轉之後並沒有 3D 的感覺,只是變瘦的 View 。

view controller's view

在真實世界中,當物體遠離我們時,由於視角的關係會看起來變小,理論上來說,比較遠的邊會比近的邊要短,但是實際上對系統來說他們是一樣長的,所以我們要做一些修正。透過設置 CATransform3D 的 m34 為 -1.0 / d 來讓影像有遠近的 3D 效果,d 代表了想像中視角與螢幕的距離,這個距離只需要大概估算,不需要很精準的計算。

解說:m34 用於按比例縮放 x 和 y 的值來呈現視角的遠近。

現在修改viewTransform()並加入一行程式碼以設置m34

func viewTransform() {
    var transform = CATransform3DIdentity
    let angle = CGFloat(45)
    transform.m34  = -1 / 500
    blueView.layer.transform = CATransform3DRotate(transform, angle, 0, 1, 0)
}

完成後,再試試執行App看看效果如何。現在那個應該有一種立體的感覺:

View3

手勢控制

接下來我們要改為手勢控制這個 View 的角度,在ViewController類別先加一個angle變數:

var angle = CGPoint.init(x: 0, y: 0)

之後,修改viewTransform方法:

func viewTransform(sender: UIPanGestureRecognizer) {
    
    let point = sender.translation(in: blueView)
    let angleX = angle.x + (point.x/30)
    let angleY = angle.y - (point.y/30)
    
    var transform = CATransform3DIdentity
    transform.m34 = -1 / 500
    transform = CATransform3DRotate(transform, angleX, 0, 1, 0)
    transform = CATransform3DRotate(transform, angleY, 1, 0, 0)
    blueView.layer.transform = transform
    
    if sender.state == .ended {
        angle.x = angleX
        angle.y = angleY
    }
}

另外,因為我們會用UIPanGestureRecognizer來識別手勢,viewDidLoad也作出相應的修改:

override func viewDidLoad() {
    super.viewDidLoad()
    
    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(viewTransform))
    blueView.addGestureRecognizer(panGesture)
}

在這邊用 CATransform3DRotate 的方法對 X 軸與 Y 軸做轉動,以手勢在 View 上每次移動的相對座標為基準(即是sender.translation(in: blueView)),直接去更改整個 View 的翻轉角度。

又再試一試執行App,嘗試用滑鼠轉動View,你會發現非常失控!

dice-rotation-crazy

因為判定旋轉角度的依據,就是手勢在這個 View 上面移動的位置,而 View 在轉動的時候座標會隨著旋轉而不停的變動,以至於手勢無法準確的控制,所以要拿一個不會動的物件當作基準,並且不能因為轉動而改變可控的面積,解決辦法如下(改動的程式碼以黃色標示):

override func viewDidLoad() {
    super.viewDidLoad()
    
    let subView = UIView.init(frame: blueView.bounds)
    subView.backgroundColor = UIColor.blue
    blueView.addSubview(subView)
        blueView.backgroundColor = UIColor.clear
    
    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(viewTransform))
    blueView.addGestureRecognizer(panGesture)
}

func viewTransform(sender: UIPanGestureRecognizer) {
    
    let point = sender.translation(in: blueView)
    let angleX = angle.x + (point.x/30)
    let angleY = angle.y - (point.y/30)
    
    var transform = CATransform3DIdentity
    transform.m34 = -1 / 500
    transform = CATransform3DRotate(transform, angleX, 0, 1, 0)
    transform = CATransform3DRotate(transform, angleY, 1, 0, 0)
    blueView.layer.sublayerTransform = transform
    
    if sender.state == .ended {
        angle.x = angleX
        angle.y = angleY
    }
}

View 需要用手勢移動當作依據,所以不直接對這個 View 做旋轉,而是旋轉 View 裡面的 sublayer,layer 裡面的有個方法可以實作這個功能 sublayerTransform ,並把內容以 subView 的方式加入,然後把 blueView 的 backgroundColor 拿掉,這樣就能很正常的轉動了。

FirstRotate

將普通的View變成骰子

接下來,就是把這個 blueView 換成骰子。然而骰子並不是平面,是一個立體的物件,那要如何在平面上做出一個立體的物件?在這邊要利用 CATransform3DTranslateCATransform3DRotate 來做出立體物件的效果,首先做骰子的 1、2、3 點。

現在先下載骰子的圖像,並將所有的圖加進Xcode項目。我們會先把 View 修改成 1 點,順便修改 View 的名稱。先將原本的blueView從storyboard 删除,之後在ViewController建立diceView

let diceView = UIView()

將以下viewDidLoad以及viewTransform方法更新至如下,同時加入一個新方法addDice

override func viewDidLoad() {
    super.viewDidLoad()

    addDice()
    
    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(viewTransform))
    diceView.addGestureRecognizer(panGesture)
}

func addDice() {
    
    let viewFrame = UIScreen.main.bounds

    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)
    
    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    
    diceView.addSubview(dice1)
    
    view.addSubview(diceView)
}

func viewTransform(sender: UIPanGestureRecognizer) {
    
    let point = sender.translation(in: diceView)
    let angleX = angle.x + (point.x/30)
    let angleY = angle.y - (point.y/30)
    
    var transform = CATransform3DIdentity
    transform.m34 = -1 / 500
    transform = CATransform3DRotate(transform, angleX, 0, 1, 0)
    transform = CATransform3DRotate(transform, angleY, 1, 0, 0)
    diceView.layer.sublayerTransform = transform
    
    if sender.state == .ended {
        angle.x = angleX
        angle.y = angleY
    }
}

我們在addDice建立相等於blue view的view並同時載入1點的圖像,如你試執行程式,應該會得到以下的效果:

OneRotate

加入骰子第 2、3 點,並分別垂直於 1 點:

func addDice() {
    
    let viewFrame = UIScreen.main.bounds
    
    var diceTransform = CATransform3DIdentity
    
    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)
    
    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    
    //2
    let dice2 = UIImageView.init(image: UIImage(named: "dice2"))
    dice2.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 0, 1, 0)
    dice2.layer.transform = diceTransform
    
    //3
    let dice3 = UIImageView.init(image: UIImage(named: "dice3"))
    dice3.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 1, 0, 0)
    dice3.layer.transform = diceTransform
    
    diceView.addSubview(dice1)
    diceView.addSubview(dice2)
    diceView.addSubview(dice3)
    
    view.addSubview(diceView)
}

又再試一試效果,看來不錯!但還是不太像一顆骰子。

3SideOrigin

要解決這問題,我們就要平移每一個 imageView。CATransform3DRotate 不是只有轉了影像,而是整個座標系統,所以只需要每個面的 z 軸都增加(減少) 50 就能夠做一半的正方體:

func addDice() {
    
    let viewFrame = UIScreen.main.bounds
    
    var diceTransform = CATransform3DIdentity
    
    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)
    
    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice1.layer.transform = diceTransform
    
    //2
    let dice2 = UIImageView.init(image: UIImage(named: "dice2"))
    dice2.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 0, 1, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice2.layer.transform = diceTransform
    
    //3
    let dice3 = UIImageView.init(image: UIImage(named: "dice3"))
    dice3.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 1, 0, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice3.layer.transform = diceTransform
    
    diceView.addSubview(dice1)
    diceView.addSubview(dice2)
    diceView.addSubview(dice3)
    
    view.addSubview(diceView)
}

現在再試試執行程式,應該有了三個面!

3Side

剩下的就是把三個對應的面加進去,就能完成一顆骰子:

func addDice() {
    
    let viewFrame = UIScreen.main.bounds
    
    var diceTransform = CATransform3DIdentity
    
    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)
    
    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice1.layer.transform = diceTransform
    
    //6
    let dice6 = UIImageView.init(image: UIImage(named: "dice6"))
    dice6.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DTranslate(CATransform3DIdentity, 0, 0, -50)
    dice6.layer.transform = diceTransform
    
    //2
    let dice2 = UIImageView.init(image: UIImage(named: "dice2"))
    dice2.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 0, 1, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice2.layer.transform = diceTransform
    
    //5
    let dice5 = UIImageView.init(image: UIImage(named: "dice5"))
    dice5.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 0, 1, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice5.layer.transform = diceTransform
    
    //3
    let dice3 = UIImageView.init(image: UIImage(named: "dice3"))
    dice3.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 1, 0, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice3.layer.transform = diceTransform
    
    //4
    let dice4 = UIImageView.init(image: UIImage(named: "dice4"))
    dice4.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 1, 0, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice4.layer.transform = diceTransform
    
    diceView.addSubview(dice1)
    diceView.addSubview(dice2)
    diceView.addSubview(dice3)
    diceView.addSubview(dice4)
    diceView.addSubview(dice5)
    diceView.addSubview(dice6)
    
    view.addSubview(diceView)
}

在這邊要注意的是 1 跟 6 都沒有旋轉,所以要一正一負。好了!終於完成,現在執行程就會有一顆會旋轉骰子。

Dice

希望你透過這個實作對 CATransform3D 有所了解,如有問題,請留言給我。另外,你可以在這裡下載完整的範例項目

作者
王 富生
iOS開發工程師,對於嘗試新的技術很有興趣,也常常分享學習經驗,並喜歡把想到的東西實作出來。除了工作之外也喜歡閱讀哲學、心理學相關書籍。歡迎研究討論,可以透過email聯絡我 [email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。