눈을 항상 위에서 아래로 내리게 하기

참고글: https://nshipster.com/cmdevicemotion/

눈을 내리도록 했는데, 눈이 유저가 기기를 돌려도 계속 중력의 방향으로 오게 하고 싶다면 어떻게 해야할까?

기기를 돌리는 각도만큼의 반대로 레이어를 돌려버리면 된다.

예를 들어

기기

  1. 똑바로 세우고 있다면 portrait
  2. 왼쪽으로 45도 눕혔다면
  3. 왼쪽으로 60도 눕혔다면
  4. 왼쪽으로 90도 눕혔다면 landscape

기기 각도

  1. 0도
  2. -45도
  3. -60도
  4. -90도

레이어 각도

  1. 0도
  2. 45도
  3. 60도
  4. 90도

근데 슬프게도 딱 기기 각도를 뱉어주는 API는 없다.

대신 CMMotionManager의 Accelerometer를 이용할 수 있다. 가속도를 나타내므로 속력, 운동방향, 시간을 포함하는 정보를 준다.

Accelerometer의 x, y를 이용하면 된다. atan2를 이용해서 x,y 좌표를 이용해 각도를 구할 수 있으니까!

여기서 양의 x축 기준으로 원하는 각도를 뱉어주는 x,y 좌표를 보면(마지막 열)

중간 열은 실제 x,y 값이다. https://nshipster.com/cmdevicemotion/ 참고하면 x, y +-가 되는 방향을 알 수 있다

  1. 똑바로 세우고 있다면 portrait
  2. 왼쪽으로 45도 눕혔다면
  3. 왼쪽으로 60도 눕혔다면
  4. 왼쪽으로 90도 눕혔다면 landscape
  1. (0, -1)
  2. (-루트2, -루트 2)
  3. (-루트2-a, -루트2+a)
  4. (-1,0)
  1. (1,0)
  2. (루트2, 루트2)
  3. (루트2-a, 루트2+a)
  4. (0,1)

실제 x,y에서 y,x로 바꾸고 양쪽에다 -를 곱해주면 된다.

atan2에 넣게되면 x, y는 y,x로 바뀌기 때문에 양쪽에 -만 곱해주면 된다.

func rotateSnowView() {
        if motion.isAccelerometerAvailable {
            motion.accelerometerUpdateInterval = 1.0 / 60.0
            motion.startAccelerometerUpdates(to: .main) {
                [weak self] (data, error) in
                guard let data = data, error == nil else {
                    return
                }

                let rotation = atan2(-data.acceleration.x,
                                     -data.acceleration.y)
                self?.snowView.transform =
                    CGAffineTransform(rotationAngle: CGFloat(rotation))
                
                
            }
        }
    }

근데 이렇게 했을 때 미세한 떨림이 나타난다.

회전하는 것 뿐만아니라 공간에서 기기를 움직여버리기 때문에, 회전 외의 값들이 가속도계에 영향을 주기 때문이다. 그럼 회전축에 관련된 값을 이용해보자. 회전축을 이용해서 Core Motion은 중력 작용의 가속도에서 유저의 움직임을 분리해준다. CMDeviceMotion 값을 이용하면 된다.

func rotateSnowView() {
        if motion.isAccelerometerAvailable {
            motion.accelerometerUpdateInterval = 1.0 / 60.0
            motion.startAccelerometerUpdates(to: .main) {
                [weak self] (data, error) in
                guard let data = data, error == nil else {
                    return
                }

                let rotation = atan2(-data.acceleration.x,
                                     -data.acceleration.y)
                self?.snowView.transform =
                    CGAffineTransform(rotationAngle: CGFloat(rotation))
                
                
            }
        }
    }

눈 내리는 효과 만들기

  1. https://zeddios.tistory.com/428 CAEmitterLayer property 이해하기
  2. https://zeddios.tistory.com/427 CAEmitterCell property 이해하기
import Foundation
import UIKit

class SnowingView: UIView {
    let emitterLayer = CAEmitterLayer()

    override public init(frame: CGRect) {
        super.init(frame: frame)
        
        setupLayer()
        setupEmitterLayer()
        layer.addSublayer(emitterLayer)
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        emitterLayer.frame = bounds
        layoutEmitterLayer()
    }
    
    private func setupLayer() {
        layer.backgroundColor = UIColor.clear.cgColor
    }
    
    private func setupEmitterLayer() {
        layoutEmitterLayer()
        
        emitterLayer.seed = UInt32(Date().timeIntervalSince1970) //레이어나 셀에 평균값(M)과 범위값(R)을 가진 특성들이 있는데, 그 값은  [M - R/2, M + R/2] 범위에 일률적으로 분포되어 있다. 여기서 숫자값을 뽑기 위한 랜덤 시드 설정. 보통의 경우처럼 현재 시간을 준 것이다. 시드가 같으면 랜덤 넘버가 같은 값이 나오는데, 시간을 기준으로 해서 계속 다르게 나올 것이다.

        emitterLayer.drawsAsynchronously = true //그리는데 백그라운드 쓰레드도 사용해서 빠르게 하겠다
        
        emitterLayer.emitterCells = emitterCells()
    }
    
    private func layoutEmitterLayer() {
        emitterLayer.emitterSize = CGSize(width: bounds.width, height: 1)
        emitterLayer.emitterPosition = CGPoint(x: bounds.width / 2, y: 0)
        emitterLayer.emitterShape = .line //눈이니까 위에서 아래로 떨어지는 모양
    }
    
    private func emitterCells() -> [CAEmitterCell] {
        var cells: [CAEmitterCell] = []
        let count = 14
        for _ in 0..<count {
            cells.append(emitterCell())
            
        }
        return cells
    }
    
    //MARK: - Emitter Cell
    private func emitterCell() -> CAEmitterCell {
        let cell = CAEmitterCell()
        cell.contents = snowflake()
        cell.beginTime = CACurrentMediaTime()
        
        cell.color = UIColor.white.cgColor
        cell.velocity = CGFloat([Int.random(in: 50...200)].shuffled().first ?? 0) //내려가는 속도
        cell.velocityRange = 100
        
        cell.birthRate = 2 //초당 생성되는 거. layer꺼 * cell꺼
        cell.lifetime = 15 //각 셀의 수명 (초) 
                   //lifetimeRange: 수명의 범위 +- lifeTime이 10초고 range가 3이면 7초~13초
            
        cell.alphaRange = 1
        cell.alphaSpeed = -0.15 //0.15만큼씩 흐려진다.
        cell.scale = 0.3 //크기
        cell.scaleRange = 0.15
        cell.scaleSpeed = -0.01 //크기도 약간씩 작아짐
        cell.spin = CGFloat.random(in: -180...180).degreesToRadians //cell 회전 속도
        cell.spinRange = CGFloat.random(in: -180...180).degreesToRadians //생애 동안 회전이 얼마나 변하느냐
            
        cell.emissionRange = 35.degreesToRadians //방출각
        cell.emissionLongitude = 180.degreesToRadians //경도에서의 방출각
        cell.xAcceleration = CGFloat.random(in: -70...70)
        cell.yAcceleration = CGFloat.random(in: -40...30)
        return cell
    }
    
    private func snowflake() -> CGImage? {
        let contentNames = ["medium1", "medium2", "medium3", "small1", "small2", "small3"] //눈송이 이미지. 각기 좀 크기 다르고 다르게 생김
        let contentIndex = Int.random(in: 0...contentNames.count-1)
        let contentName = contentNames[contentIndex]
        if let image = UIImage(named: contentName),
            let filter = CIFilter(name: "CIGaussianBlur") {
            let cgImage = CIImage(cgImage: image.cgImage!)
            
            filter.setDefaults()
            filter.setValue(cgImage, forKey: kCIInputImageKey)
            let radius = CGFloat.random(in: 0...3)
            filter.setValue(radius, forKey: kCIInputRadiusKey)
            
            let context = CIContext(options: nil)
            let imageRef = context.createCGImage(filter.outputImage!, from: cgImage.extent) //이미지에 블러를 씌운다.
            return imageRef
        }
        return nil
    }
}

extension BinaryInteger {
    var degreesToRadians: CGFloat { return CGFloat(Int(self)) * .pi / 180 }
}

extension FloatingPoint {
    var degreesToRadians: Self { return self * .pi / 180 }
}
  1. xAcceleration, yAcceleration은 내려지는 눈에 가해지는 방향 꺾임
    1. 사용 했을 때/ 안 했을 때

2. emissionLongitude 사용 했을 때(180도)/ 안 했을 때(0)

  • azimuth 방위각이라고도 한다. https://ko.wikipedia.org/wiki/방위각
  • emissionLongitude를 0으로 주면 애들이 잘 안 떨어지고, 거의 수평하게 떨어져서 안 보인다.
    • 오른쪽으로 날리는 것 같아 보이지만 , 저시기에만 그런거고 좌우로 랜덤임
    • 위키 이미지를 보면 0도면 북쪽을 향하고(가끔 다른게 있는건 acceleration 때문), 남쪽을 향하게 하려면 180으로 해서 남쪽을 행하게 줘야 한다.
  • acceleration 제외하고 각각 90도 270도를 하면 각기 동쪽으로, 서쪽으로 향한다

3. acceleration 제외하고 emissionRange 사용 했을 때(180도)/ 안 했을 때(0도). 방출각을 사용하면 그 각도 만큼 각 셀이 다양하게 흩뿌려진다.