카테고리 없음

[JS] 눈 내리는 이펙트

개발하는 가오나시 2024. 1. 5. 14:01

웹에서 눈이나 꽃이 내리는 이펙트를 본 적이 있다. 이게 참 낭만이 있다 느껴져서 직접 구현해보기로 했다.

 

환경은 Next.js 에서 js와 css만을 가지고 만들어 본다.

 

필요한 데이터

단순하게 "눈이 내린다 > 와 이쁘다" 이렇게 눈과 마음으로 즐기면 안된다. "눈이 내린다 > 눈이 내리는 속도, 바람에 의해서 흔들리는 것" 등등 우리는 이런 것을 계산해야한다.

 

기본적으로 눈의 좌표를 가지고 있어야한다. 우리의 세상은 3d이지만 브라우저는 기본적으로 2d니까 x,y좌표만 있으면 된다. 그 다음 눈이 내리는 속도 이다.

 

정리하면 x,y좌표와 눈의 속도 가 되겠다.

 

구현

먼저 눈의 초기값을 만들어준다.

const makeDefaultFaillingData = (count: number = 10) => {
  if (typeof window !== 'undefined') {
    const datas: FaillingData[] = [];
    const innerWidth = window.innerWidth;
    const innerHeight = window.innerHeight;

    for (let i = 0; i < count; i++) {
      const data = {
        x: Math.floor(Math.random() * innerWidth - 10),
        y: Math.floor(Math.random() * innerHeight),
        speedOfFall: Math.random() / 2 + 0.1
      };
      datas.push(data);
    }

    return datas;
  } else {
    return [];
  }
};

 

광활한 브라우저의 화면에서 전체적으로 내리는 눈을 구현하기 위해서는 window의 innerWidth를 가져와서 Math.random을 이용하여 너비에 랜덤한 x좌표를 잡아준다. y좌표와 속도도 랜덤으로 잡아준다. Next.js 환경이기 때문에 Client와 Server를 구분해서 분기 처리 해준다.

 

const failling = (faillingData: FaillingData[]) =>
  faillingData.map(data => {
    let x = data.x + Math.cos(data.y / 30);
    let y = data.y + data.speedOfFall;

    const innerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
    const innerWidth = typeof window !== 'undefined' ? window.innerWidth : 0;

    if (y >= innerHeight - 60) {
      y = 0;
      x = Math.floor(Math.random() * innerWidth);
    }
    if (x > innerWidth - 50) {
      x = innerWidth - 50;
    } else if (x < 0) {
      x = 50;
    }
    return { x, y, speedOfFall: data.speedOfFall };
  });

 

눈이 바람에 따라 흔들리며 내려오는 효과를 연출하기 위해서 삼각함수의 cos를 이용해준다. cos의 그래프는 물결 모양으로 눈이 하늘하늘 내리는 연출을 주기에 적합하다. 이때 일정하게 증가하는 y 좌표를 이용하여 cos의 값을 만들어 주면 물결모양으로 움직이는 x좌표를 만들 수 있다. 

 

map을 이용하여 눈들을 순회하면서 정보를 업데이트 해준다. 이때 몇 가지 분기를 해줘야하는데, 눈의 x좌표가 브라우저의 양 옆을 넘어가는 경우를 방지하기 위함이 하나이며, y좌표가 브라우저의 바닥에 도착해서 새로운 눈이 되는 부분이다.

 

이렇게 만들어진 함수들을 재사용 가능하도록 custom hook으로 만들어준다.

interface FaillingData {
  x: number; // x좌표
  y: number; // y좌표
  speedOfFall: number; // 낙하 속도
} //정보

export const useFalling = (count: number): FaillingData[] => {
  const [faillingDatas, setFaillingData] = useState<FaillingData[]>(makeDefaultFaillingData(count));
  const fallingAnimationRef = useRef(0);

  const setSnowPosition = () => {
    setFaillingData(state => failling(state));
    requestAnimationFrame(setSnowPosition);
  };

  useEffect(() => {
    if (window === undefined) return;
    fallingAnimationRef.current = requestAnimationFrame(setSnowPosition);

    return () => {
      cancelAnimationFrame(fallingAnimationRef.current);
    };
  }, []);

  return faillingDatas;
};

 

이때 눈의 data를 주기적으로 바꿔서 좌표값을 업데이트 해주기 위해 requestAnimationFrame을 사용했다. 다른 방법으로는 setInterval을 사용할 수 있으나, setInterval의 경우 지정한 시간에 맞춰(최소 4ms) 함수를 실행해준다.

requestAnimationFrame을 선택한 이유는 다음과 같다.

자바스크립트는 단일 호출 스택(콜 스택)이기 때문에 스택 상황에 따라 동작하는데, 작업이 쌓이는 queue들의 우선순위가 다르다. task-queue의 종류로는

1. Tesk queue  2.Micro Tesk Queue  3. Animation Frames

 

이렇게 존재하며 우선순위는 2 > 3> 1 이 된다. setInterval의 경우 1번의 큐에 들어가고, requestAnimationFrame의 경우 3번의 큐에 해당한다. 그렇기에 requestAnimationFrame가 실행 우선순위가 높다.

 

또한 requestAnimationFrame는 setInterval이 인자로 받는 타이머 값을 받지 않는다. 대신 대게 1초에 60번을 기준으로 호출된다. 즉 60fps를 기준으로 호출되는데, 이는 모니터의 주사율에 맞게 호출한다.

 

interface FallingBgProps {
  amount: number;
}

const FallingBgContainer = styled.div`
  position: fixed;
  z-index: 0;
  height: 1px;
`;


const get_falling_stuff = () => {
  const date = new Date();
  const nowMonth = date.getMonth();

  switch (nowMonth) {
    case 11:
    case 0:
    case 1:
      return '*';
    case 2:
    case 3:
    case 4:
      return '🌸';
    case 5:
    case 6:
    case 7:
      return '🍀';
    case 8:
    case 9:
    case 10:
      return '🍁';
    default:
      return '*';
  }
};

export const FallingBg = (props: FallingBgProps) => {
  const { amount } = props;
  const faillingData = useFalling(amount);
  const fallingStuff = useMemo(() => get_falling_stuff(), []);

  return (
    <FallingBgContainer>
      {faillingData.map((data, idx) => (
        <div
          key={idx}
          style={{
            fontSize: '15px',
            color: `#fff`,
            transform: `translate(${data.x}px, ${data.y}px)`,
            position: 'absolute',
            left: 0,
            top: 0
          }}
        >
          {fallingStuff}
        </div>
      ))}
    </FallingBgContainer>
  );
};

 

위에서 만든 customHook을 이용하여 컴포넌트를 만들어 준다. 이때 left나 top을 변경하는 것이 아닌, transform을 변경하는 이유는 

transform은 reflow나 repaint를 유발하지 않기 떄문이다. 또한 gpu를 이용한 계산을 하지만 absolute position을 이용하는 경우 cpu를 이용하여 계산하기에 성능의 차이도 날 수 있다.

 

자 그러면 달 별로 하늘에서 내리는 물체를 바꿔주고

<FallingBg amount={40} />

 

 

이제 하늘에서 눈이 내린다.

화면 기록 2024-01-05 오후 2.00.36.mov
0.60MB

 

 

참조 :

https://iamsjy17.github.io/javascript/2019/07/20/how-to-works-js.html https://velog.io/@younghwanjoe/requestAnimationFrame%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-%EC%83%81