React Native) Gyroscope sensor로 기울어지는 hologram card를 구현해보자

2ast·2025년 6월 21일

React Native) 자이로스코프로 홀로그램 카드 만들기

포켓몬 카드 인터랙션 웹사이트라는 사이트가 몇 년 전 화제가 되었었다. 포켓몬 카드가 사용자 마우스에 따라 기울이지며 다양한 홀로그램 이펙트가 보여지는데, 최근 RN에서 동일한 시도를 한 레포를 발견했다. 그 후 자이로스코프 센서를 이용한 UI 레퍼런스를 보고 영감을 받았고, 기존 레포에서 아쉬웠던 홀로그램 구현을 디벨롭하는 동시에 자이로센서를 활용해 카드 기울임을 구현해보기로 했다. 결과적으로 실제 카드를 손에 들고 기울이는 것처럼 자연스러운 홀로그램 카드를 만드는데 성공했고, 이 글에서는 '어떻게' 구현했는지에 초점을 맞춰서 설명을 이어나갈 예정이다. 끝 부분에 전체 코드를 담은 repo도 공개할테니 전체적인 흐름만 잡고 가도 좋을 것 같다.

자이로스코프 센서 사용하기

우선 나는 편리한 Expo 환경에서 구현하기로 결정했고, Expo에서는 expo-sensors 모듈을 사용해 자이로스코프에 접근할 수 있다.

import { Gyroscope } from 'expo-sensors';
import { useFocusEffect } from 'expo-router';

useFocusEffect(
  useCallback(() => {
    Gyroscope.setUpdateInterval(16); // 60fps
    
    const unsubscribe = Gyroscope.addListener((gyroscopeData) => {
      console.log(gyroscopeData);
    });
    
    return () => {
      unsubscribe.remove();
    };
  }, [])
);

간단하게 setUpdateInterval(16) 부분만 한 번 짚고 가자면, 각 listener 호출 빈도를 조정하는 메서드로서, 16ms(60Hz) 간격으로 센서 데이터를 갱신하면 부드러운 애니메이션을 구현할 수 있다. 다만 주의할 점은 Android에서 200ms 미만의 센서 갱신 주기를 사용하려면 별도의 권한을 요청해야 한다. 이부분을 놓치고 'android에서 성능이 왜이렇지?'라고 생각하는 (나 같은)사람이 없기를 바란다.

{
  "expo": {
    "android": {
      "permissions": ["android.permission.HIGH_SAMPLING_RATE_SENSORS"]
    }
  }
}

Reanimated로 3D 틸트 효과 구현

자이로스코프 데이터를 받아서 카드를 3D로 기울이는 컴포넌트를 만들어보자.

import Animated, { 
  useAnimatedStyle, 
  useSharedValue, 
  withTiming 
} from 'react-native-reanimated';

const MAX_ANGLE = 15;
const RAD2DEG = 180 / Math.PI;

function clamp(v: number, min: number, max: number) {
  'worklet';
  return Math.min(Math.max(v, min), max);
}

const TiltCard = ({ style, children }) => {
  const rotateX = useSharedValue(0);
  const rotateY = useSharedValue(0);

  const rStyle = useAnimatedStyle(() => ({
    transform: [
      { perspective: 1000 },
      { rotateX: `${rotateX.value}deg` },
      { rotateY: `${rotateY.value}deg` },
    ],
  }), []);

  useFocusEffect(
    useCallback(() => {
      Gyroscope.setUpdateInterval(16);

      let prev = Date.now();
      const unsubscribe = Gyroscope.addListener((gyroscopeData) => {
        const now = Date.now();
        const dt = (now - prev) / 1000;
        prev = now;

        rotateX.value = clamp(
          rotateX.value + (gyroscopeData.x / 2) * dt * RAD2DEG,
          -MAX_ANGLE,
          MAX_ANGLE
        );
        rotateY.value = clamp(
          rotateY.value - (gyroscopeData.y / 2) * dt * RAD2DEG,
          -MAX_ANGLE,
          MAX_ANGLE
        );
      });

      return () => {
        rotateX.value = withTiming(0, { duration: 500 });
        rotateY.value = withTiming(0, { duration: 500 });
        unsubscribe.remove();
      };
    }, [])
  );

  return (
    <Animated.View style={[rStyle, style]}>
      {children}
    </Animated.View>
  );
};

자이로스코프에서 받는 데이터는 초당 회전각(rad/s) 형태다. 이걸 카드 기울임 각도(deg)로 변환해야 한다.

rotateX.value + (gyroscopeData.x / 2) * dt * RAD2DEG
  • gyroscopeData.x: 초당 회전각 (rad/s)
  • dt: 콜백 호출 간격 (초)
  • RAD2DEG: 라디안을 도로 변환 (180/π)

결과적으로 rad/s × s × deg/rad = deg가 되어서 정확한 기울임 각도를 얻을 수 있다. 참고로 gyroscopeData.x를 2로 나눈 것 단순히 민감도를 조정하기 위한 임의의 step이었고, 원하는 UX에 맞춰 설정하면 된다.

React Native Skia로 빛 반사 효과 만들기

단순히 3D 회전만 하면 재미없다. 실제 카드 같은 느낌을 주려면 빛 반사 효과가 필요하다. @shopify/react-native-skia를 사용해서 구현했다.

import {
  Canvas,
  LinearGradient,
  RoundedRect,
  useDerivedValue
} from '@shopify/react-native-skia';

const TiltCard = ({ width, height, style, children }) => {
  // 기울임에 따라 움직이는 그라디언트 시작/끝점
  const gradientStart = useDerivedValue(() => ({
    x: -width + (width / 2 + (width / 2) * (rotateY.value / MAX_ANGLE)),
    y: -height + (height / 2 + (height / 2) * (rotateX.value / MAX_ANGLE)),
  }));

  const gradientEnd = useDerivedValue(() => ({
    x: width + (width / 2 + (width / 2) * (rotateY.value / MAX_ANGLE)),
    y: height + (height / 2 + (height / 2) * (rotateX.value / MAX_ANGLE)),
  }));

  return (
    <Animated.View style={[rStyle, style]}>
      {children}
      
      <Canvas
        pointerEvents={'none'}
        style={[StyleSheet.absoluteFill, { width, height }]}
      >
        <RoundedRect x={0} y={0} r={12} width={width} height={height}>
          <LinearGradient
            start={gradientStart}
            end={gradientEnd}
            colors={[
              'rgba(0, 0, 0, 0)',
              'rgba(255, 255, 255, 0.3)',
              'rgba(0, 0, 0, 0)',
              'rgba(255, 255, 255, 0.2)',
              'rgba(0, 0, 0, 0)',
            ]}
            positions={[0, 0.35, 0.5, 0.65, 1]}
          />
        </RoundedRect>
      </Canvas>
    </Animated.View>
  );
};

빛이 비추는 것처럼 보이도록 흰색의 사선 그라디언트를 카드에 overlay 해주었다. 그리고 기울임에 따라 빛이 움직이는 것처럼 보이도록 useDerivedValue로 gradientStart, gradientEnd를 계산해주었다. gradientStart, gradientEnd의 계산 식은 단순히 rotate에 따라 x,y를 특정 범위 안쪽 값으로 변환해주는 interpolation 로직일 뿐이다. 이렇게 하면 빛이 카드 표면을 따라 자연스럽게 움직이는 효과를 만들 수 있다.

홀로그램 효과 구현하기

이제 진짜 핵심인 홀로그램 효과를 만들어보자. 먼저 홀로그램의 바탕이 될 무지개 색상 그라디언트를 만들어 카드에 오버레이 해준다. 아까 빛 반사 효과와 같이 gradientStart, gradientEnd값을 넣어주면 카드를 움직임에 따라 자연스럽게 색상이 변하는 이펙트를 줄 수 있다.

<RoundedRect x={0} y={0} r={12} width={width} height={height}>
  <LinearGradient
    start={gradientStart}
    end={gradientEnd}
    colors={[
      '#ff3b30', '#ff9500', '#ffcc00', 
      '#4cd964', '#34aadc', '#5856d6', '#ff3b30'
    ]}
  />
</RoundedRect>

그 다음 원하는 홀로그램 패턴 이미지로 마스킹한다. (홀로그램 패턴 이미지는 조금 더 자연스러운 효과를 위해 노이즈 효과를 넣어 사용하면 좋다.)

const hologramMask = useImage(require('../assets/holo_background.png'));

const renderHologramLayer = () => (
  <Group blendMode="overlay">
    <Mask
      mask={
        <RoundedRect x={0} y={0} r={12} width={width} height={height}/>
      }
    >
      <Image image={hologramMask} width={width} height={height} fit="cover" />
      <RoundedRect x={0} y={0} r={12} width={width} height={height}>
        <LinearGradient
          start={gradientStart}
          end={gradientEnd}
          colors={['#ff3b30', '#ff9500', '#ffcc00', '#4cd964', '#34aadc', '#5856d6', '#ff3b30']}
        />
      </RoundedRect>
    </Mask>
  </Group>
);

이제 마지막으로 '빛이 비추는 곳만 홀로그램이 보이도록' Mask option을 조정해주면 된다.

const renderHologramLayer = () => (
    <Group blendMode="overlay">
      <Mask
        mask={
          <RoundedRect x={0} y={0} r={12} width={width} height={height}>
            <LinearGradient
              start={gradientStart}
              end={gradientEnd}
              colors={[
                'rgba(0, 0, 0, 0)',
                'rgba(255, 255, 255, 0.8)',
                'rgba(0, 0, 0, 0)',
                'rgba(255, 255, 255, 0.7)',
                'rgba(0, 0, 0, 0)',
              ]}
              positions={[0, 0.35, 0.5, 0.65, 1]}
            />
          </RoundedRect>
        }
        mode="luminance"
      >
        <Image image={hologramMask} width={width} height={height} fit="cover" />
        <RoundedRect x={0} y={0} r={17} width={width} height={height}>
          <LinearGradient
            start={gradientStart}
            end={gradientEnd}
            colors={[
              '#ff3b30',
              '#ff9500',
              '#ffcc00',
              '#4cd964',
              '#34aadc',
              '#5856d6',
              '#ff3b30',
            ]}
          />
        </RoundedRect>
      </Mask>
    </Group>
  );

mask props에는 아까 구현해두었던 '빛 반사' 효과 LinearGradient에서 색상만 밝은 색으로 바꾸어 설정해주었다. 여기서 핵심은 mode="luminance"인데, mask의 '밝기' 기준으로 아래 이미지를 Masking해주기 때문에 자연스럽게 빛이 비추는 곳만 홀로그램이 보이도록 효과를 줄 수 있다.

전체 소스코드를 담은 public repo 주소도 첨부하니 실제로 사용해보며 코드를 살펴봐도 좋을 것 같다.

참고

profile
React-Native 개발블로그