각도에 따라 빛나는 카드 뷰 만들기 [미완성]

안녕하세요!
최근 WWDC25에서 애플이 Liquid Glass를 발표했습니다.
저도 관련 영상을 찾아보았는데.. 유리처럼 투명한 UI에 빛 반사와 굴절까지 적용이 되더라구요?!
마치 사용자가 인터랙션하는 대로 반응하는 것처럼 말이죠..! 🙂

그리고 최근에 한 영상을 보게 되었는데요..
바로 코딩 애플 선생님의 포켓몬 카드 뷰 만드는 영상입니다!

포켓몬 카드는 이리저리 움직일 때 홀로그램처럼 반짝이는 효과가 있는데요..
이걸 웹에서 구현하여 마우스 포인터에 따라 카드가 회전하면서 빛나는 효과를 주고 있습니다.

참 신기하지 않나요.. 😮
인터렉티브한 디자인은 확실히 서비스에 대한 몰입도를 높여주는 것 같습니다.
그래서!! 저도 iOS에서 이리저리 빛나는 카드 뷰를 똑같이 만들어보기로 했습니다.



[참고]

[Apple 공식 문서 - Getting processed device-motion data]

[Apple 공식 문서 - CMAttitude]



CMAttitude

원본 영상에서는 마우스 포인터의 움직임에 따라 카드가 회전하는데요!
저는 디바이스의 회전에 따라 카드가 움직이도록 구성했습니다.

iOS에서는 디바이스 움직임을 CoreMotion 프레임워크를 통해 측정할 수 있습니다.
디바이스의 자세는 CMAttitude로 표현되고 이는 회전 행렬(rotation matrix), 쿼터니언(quaternion), 오일러 각도(roll, pitch, yaw)를 제공합니다.
이는 어떤 물체가 회전한 방향을 x, y, z 축 기준으로 설명한 방식입니다.

방향

위 사진에서 잘 설명하고 있죠!

기준 좌표계를 기준으로 roll, pitch, yaw 값은 0으로 시작하여 라디안 단위로 제공됩니다.
pitch 값은 -π/2 ~ +π/2, roll, yaw 값은 -π ~ +π 사이의 값을 얻을 수 있습니다.
실제로 측정해보면 디바이스가 누워있을 때 기준으로 pitch 값은 디바이스가 세워졌을 때 +π/2까지 증가했다가 디바이스가 아래를 향할 때 -π/2까지 측정이 됩니다. 이때 쓰러지는 방향은 관계 없습니다.

하지만 pitch, roll 값을 기준으로 회전 연산을 계산해줄 때 문제가 있었습니다…
예를 들어 처음에 디바이스가 누워있을 때는 roll값이 좌우 기울임에 대해 잘 측정되지만, 이 상태에서 디바이스를 세로로 세우면 더 이상 좌우 기울임 값을 기준으로 측정하지 않는 것처럼 보였습니다.


xArbitraryZVertical를 기준으로 지면에 수직인 z축을 사용하는데, 여기서 디바이스를 세우면 z축이 지면과 수평이 됩니다. 이러면 기존의 좌우 회전이 z축에 대한 회전으로 인식되면서… 뭔가 꼬이는 것이 아닌가 생각했는데요 😅

https://stackoverflow.com/questions/69216465/the-simplest-way-to-solve-gimbal-lock-when-using-deviceorientation-events-in-jav

좀 더 찾아보니 비슷한 문제가 있습니다.
결국 어떤 기준점을 가지고 Attitude를 측정하고 있는데, 이 기준점이 변하면서 발생하는 문제인 것으로 보입니다.
그래서 해결법 중 회전 행렬을 사용하여 왜곡이 발생하지 않는 기준점으로 오일러 각을 다시 구하는 방법도 있네요..


오일러 각과 짐벌 락 (삽질의 시작)

이런 문제는 짐벌 락(Gimbal Lock) 때문에 발생한다고 하는데요..
찾아보니 Unity와 같은 게임 개발에서 회전 시 많이 알려진 개념인 것 같습니다.

물체를 회전할 때 각 roll, pitch, yaw라는 중심 축을 기준으로 회전하는 모양을 고리로 나타낼 수 있습니다.

img (6)

그래서 이렇게 3개 축을 기준으로 고리가 회전합니다.
오일러 각은 이렇게 3차원에서 강체가 회전하는 방향과 모양을 세개의 축을 기준으로 표현한 것입니다.
a라는 상태에서 x축을 돌렸다고 생각했을 때 y, z 는 같이 돌게 됩니다.
이때 y축에도 어떤 변동이 생겼다고 가정하면… 기존의 a를 기준으로 도는 것이 아니라 a’를 기준으로 돌게 됩니다.
마지막으로 z는 a’‘에 대해서 돌기 때문에 세 축이 의존적인 연산을 하게 됩니다.
x에 대한 회전행렬 Rx, y에 대한 회전행렬 Ry, z에 대한 회전행렬 Rz가 있으면 RzRyRxX로 계산하게 되니 결국 이전 결과에 종속됩니다.
(축 순서에 따라 오일러 각도 여러 버전이 있다고 하는데… 어렵네요..😓)

여기서 짐벌락이란 축이 순차적으로 회전할 때 두 축이 같은 방향으로 작동하게 되는 현상입니다.
두 축이 겹쳐버리면 어느 축을 기준으로 회전하던 동일한 결과가 나옵니다.
결국에는 두 회전이 모두 하나의 축을 기준으로 동작하게 됩니다.
이때 오일러 각을 계산하면 수치적으로 불연속적인 점프가 발생할 수 있다고 합니다.
왜냐하면 결론적으로는 동일한 방향을 나타내고 있어도 값의 결과는 두 회전이 동일한 축을 기준으로 동작하기 떄문에 튈 수 있습니다.

이 문제를 해결하는 방법은..? 쿼터니언이 있습니다.
쿼터니언은 복소수를 확장하여 x, y, z, w의 4가지 성분을 갖는 4차원 벡터라고 합니다.
쿼터니언은 회전을 4차원으로 표현하며 기존에 축마다 의존성을 가지던 오일러 각과는 달리 세 개의 축이 동시에 움직입니다.
CMAttitude에서는 quaternion 값도 제공하고 있습니다.

정확하게는 q = w + xi + yj + zk 라고 하는데…
대강 회전에 따라 값이 다음과 같이 변동한다고 합니다. 출처

img (5)

여기서 기존 핸드폰의 z축 벡터가 (0, 0, 1)이라고 했을 때
쿼터니언으로 해당 벡터를 회전시켜서 어디를 보고 있는지 구할 수 있습니다.

이걸 계산하면 다음과 같은 수식이… 나온다고 하네요…

let q = attitude.quaternion
let deviceForward = SIMD3<Double>(
    x: 2 * (q.x * q.z + q.w * q.y),
    y: 2 * (q.y * q.z - q.w * q.x),
    z: 1 - 2 * (q.x * q.x + q.y * q.y)
)


아무튼 이렇게 해서 벡터의 x, y, z 값을 측정해보면
디바이스가 누워있을 때 (0, 0, 1), 사용자 쪽으로 디바이스를 90도 세웠을 때 (0, -1, 0)의 벡터 값이 나옵니다.
이제 디바이스의 중심 벡터를 구했으니 기존 좌표계에서 얼마나 바뀌었는지 측정해주면 되겠다고 생각했지만…..
기준이 되는 좌표계에서 수평하거나 하게 되면 회전 방향이 아예 반대로 되어버리더군요..?

그러니깐 핸드폰을 옆으로 들고 있나 앞으로 들고 있나 유저 입장에서는 동일한 각도로 들고 있지만 백터 자체는 다른 방향을 바라보고 있어서 문제가 되는 것 같습니다.

결국 저는 중력 벡터를 사용해서 구현하기로.. 했습니다 ㅎ
중력 벡터는 고정된 중력 방향에 대해 제공되는 벡터값을 쉽게 얻을 수 있으니까요..!
뭔가 기준 벡터를 잘 정하고 값을 잘 정제하면 어느 각도에서나 일정하게 회전하는 카드를 만들 수 있을 것 같은데 시간 관계상 다음에… (잘 아시는 분이 계시다면 팁을 알려주세요 🥹)
이 글의 제목이 미완성인 것도 이 부분 때문이랍니다 😅
이 부분은 CMAttitude에서 제공하는 quaternion 값의 기준과 활용에 대해서 좀 더 알아봐야 할 것 같습니다.
재미있는 작업이라서 나중에 꼭 완성해보고 싶긴 합니다. 🥹

어쨌든 중력 벡터를 써서 구현해보겠습니다. (이쯤 되니 정신이 아득해지네요…)
우선 핸드폰 정면을 기준으로 중력 벡터를 쓰기 위해서 반전을 시켜주었습니다.

let flippedGravity = CMAcceleration(
    x: -gravity.x,
    y: -gravity.y,
    z: -gravity.z
)

그리고 각 tilt의 기울기를 계산했습니다.

let horizontalTilt = -atan2(flippedGravity.x, flippedGravity.z) * 180 / .pi
let verticalTilt = atan2(flippedGravity.y, flippedGravity.z) * 180 / .pi

그리고 SwiftUI View에서는 rotation3DEffect를 사용해서 회전을 시켜주었습니다.
이때 각도는 너무 과하지 않게 -25° ~ 25° 사이로 매핑했습니다.
하이라이트의 경우에는 RadicalGradient를 임의로 생성해서 뷰에서 위치할 UnitPoint를 회전 각도 기준으로 측정해서.. 회전에 따라 하이라이트가 이리저리 움직일 수 있게 합니다.

이렇게 완성된 뷰를 보면..?


와~~
디바이스를 회전시키면 동시에 카드가 회전하며 빛이 납니다.
진짜 포켓몬 카드처럼 말이죠..!


마무리

어쩌다 보니 저는 iOS 개발을 하고 있었는데 갑자기 수학 공부를 하고 있고.. 그렇게 됐네요…..
하지만 오늘 알아본 것으로는 10%(…) 정도만 알게 된 것 같고.. 나머지 90%는 다음에 완벽히 공부해서 적용해보려고 합니다 ㅎ
왜 대학교에 전공 수업으로 선형대수라는 과목이 있는지 알 수 있었던 시간이었습니다.