Notice
Recent Posts
Recent Comments
Link
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Archives
Today
Total
관리 메뉴

성운

[three.js] 눈덩이 굴리기 (1) 본문

react/project

[three.js] 눈덩이 굴리기 (1)

pakxe_ 2025. 1. 24. 08:00

들어가며

지금 만들고 있는 프로젝트에서 눈덩이를 굴리는 인터렉션을 넣으려고 합니다. 대략적인 시안은 이렇습니다.

 

 

필요한 것은 바닥과 하늘, 그리고 눈덩이 굴리기 인터렉션 입니다.

 

 

바닥과 하늘

길다란 plane과 배경으로는 drei라이브러리의 Stars, Skys를 사용해 간단하게 밤하늘을 구현했습니다.

 

const SnowBallPage = () => {
  return (
    <Canvas shadows camera={{ position: [0, 5, 6], fov: 50 }} style={{ height: '100vh', width: '100vw' }}>
      <ambientLight intensity={0.5} />
      <directionalLight position={[0, 10, 5]} castShadow />

      <NightSky />

      <mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow position={[0, -1, 0]}>
        <planeGeometry args={[100, 100]} />
        <meshStandardMaterial color='white' />
      </mesh>
      <OrbitControls />
      <axesHelper />
    </Canvas>
  );
};

const NightSky = () => {
  return (
    <>
      <Sky
        distance={450000}
        sunPosition={[0, -1, 0]} // 태양 위치를 낮게 설정
        inclination={0}
        azimuth={0.25}
      />

      <Stars radius={100} depth={1} count={3000} factor={10} saturation={0} fade />
    </>
  );
};

 

Sky에서 sunPosition을 지하(0, -1, 0)로 두면 그럴듯한 밤하늘이 됩니다. 빠르게 하늘을 만들 때 사용할 수 있는 컴포넌트에요. 

Stars는 천구처럼 중심을 감싸는 형태로 구현됩니다.

 

눈덩이 굴리기

 

구를 하나 띄우고 onPointer... 리스너를 사용해 이 개체를 움직일 수 있도록 합니다.

 

이때 움직이는 모션은 드래그를 시작하고 움직일 때는 마우스를 따라오며 클릭이 끝났을 때는 기존에 움직이던 방향으로 움직이되 감속을 넣습니다. 그렇게 서서히 굴러가는걸 멈추게 됩니다. 실제로 눈을 굴릴 때의 느낌처럼 만드려고 합니다. 무거워서 그런지 감속이 많이 되더라구요. 

 

눈덩이 굴리기는 2단계로 나누어서 구현합니다.

1. 드래그할 때의 변화

2. 드래그가 끝났을 때의 변화

 

1번에서는 pointerMove이벤트가 발생할 때마다 position을 업데이트 해야합니다. 드래그를 하는 그 시간 사이에서 무수히 발생하는 이벤트입니다. 그래서 마지막 커서 위치를 저장해두고 현재의 커서 위치와의 차이인 delta를 구해 position을 업데이트합니다.

// 드래깅 핸들러

const [lastX, lastY] = lastPointerPos.current; // 이전 커서 값 가져오기
const deltaX = event.clientX - lastX;
const deltaY = event.clientY - lastY;

setPosition((prev) => [prev[0] + deltaX * 0.015, prev[1], prev[2] + deltaY * 0.015]); 

lastPointerPos.current = [event.clientX, event.clientY]; // 다음을 위한 저장

0.015를 곱해주는 이유는 커서 이동은 픽셀 단위로 측정되기 때문입니다. 1픽셀 움직였다고 하고 그 1값을 그대로 position으로 업데이트한다면 실제 three의 좌표 1만큼 이동되기 때문에 굉장히 큰 이동이 됩니다. three에서 1은 체감 상 거의 2센치정도 됩니다.

 

(공크기 += 1을 해주기만 해도 엄청난 차이가 있습니다. three에서 1은 마우스 이동량에 비해 매우 큰 값)

 

 

데굴데굴

 

이제 2번인 감속을 구현해봅시다. 지금의 눈덩이는 드래그하는대로 움직이기만합니다. 실제 눈덩이는 손에서 떨어져도 마지막 힘에 따라 약간의 데굴데굴 굴러가는 느낌이 있습니다. 

 

사용자가 마우스로 뭔가 액션을 취해 발생하는 일이 아니기 때문에 useFrame을 사용해 알아서 실행하도록 했습니다. pointerUp을 사용해 드래깅이 끝났을 때 서서히 감속하는건 어떤가 했는데, 이 경우 함수가 한 번만 실행되고 프레임별로 서서히 감속(position변화)하는 모습을 구현하기 어렵다고 생각되었어요. 

 

이때 useFrame을 트리거하는건 드래깅이 끝난 상황이어야 합니다. 이 상황을 다루기 위해 isDragging상태를 만들고 드래깅이 시작되었을 때 true로, 끝났을 때 false로 만듭니다. 

const [isDragging, setIsDragging] = useState(false);

// 드래그 시작
const handleDragStart = (event) => {
    setIsDragging(true);
    lastPointerPos.current = [event.clientX, event.clientY]; 
};

// 드래그 끝
const handleDragEnd = (event) => {
    setIsDragging(false);
};

 

 

그리고 false일 때만 useFrame(감속)을 실행하도록 합니다.

다만 감속을 위해 필요한 것은 드래깅에서의 마지막 속도입니다. 그래서 마지막 속도를 드래깅 핸들러에서 계속 계산해 저장하고 있도록 합니다. 이렇게 업데이트된 속도를 useFrame안에서 서서히 감속시키면서 position이동을 할 때 사용합니다. 

// 드래깅 핸들러

const [lastX, lastY] = lastPointerPos.current;
const deltaX = event.clientX - lastX;
const deltaY = event.clientY - lastY;

setVelocity([deltaX * 0.02, deltaY * 0.02]);

가중치를 0.02을 준 이유는 감속이 frame마다 될 건데, 1에 가까울 수록 총알처럼 날아가는 눈덩이가 됩니다. 눈덩이는 무겁기 때문에 감속량을 많이 주었어요. 

 

이제 이 속도를 기반으로 드래깅이 끝났을 떄 position을 업데이트 시키도록 합니다. 이때 velocity값도 서서히 낮추지 않으면 그대로 같은 속도를 유지하며 날아가기 때문에 velocity도 서서히 줄여줍니다.

useFrame(() => {
    if (!isDragging && velocity[0] !== 0 && velocity[1] !== 0) {
      setPosition((prev) => [prev[0] + velocity[0], prev[1], prev[2] + velocity[1]]);

      // 속도 감속 (마찰 효과)
      setVelocity((prev) => [prev[0] * 0.8, prev[1] * 0.8]);

      // 속도가 거의 0이면 멈춤
      if (Math.abs(velocity[0]) < 0.01 && Math.abs(velocity[1]) < 0.01) {
        setVelocity([0, 0]);
      }
    }
  });

현재 위치한 position에서 velocity만큼을 곱해 드래깅이 끝나도 움직이도록 합니다. 그리고 이 velocity도 점차 줄어들도록 해 드래깅이 끝나고 수 초 이내로 눈덩이가 멈추도록 합니다.

 

useFrame을 최대한 덜 실행시키기 위해 velocity가 0이라면 실행하지 않도록 합니다. 다만 0.8씩 감속한다고 하면 영원히 0에 다다르지 않기 때문에 속도가 아주 작은 값이 되었을 때는 0으로 강제 초기화합니다. 이때 속도는 방향에 따라 음수일 수도 있으므로 abs를 적용합니다. 

 

지금까지 만들어진 구현은 다음과 같습니다.

// 드래그 시작 - 드래깅 상태 true로, 커서 위치 set
const handleDragStart = (event) => {
    setIsDragging(true);
    lastPointerPos.current = [event.clientX, event.clientY];
  };

// 드래그 중 = position, velocity 업데이트
  const handleDragging = (event) => {
    if (isDragging) {
      const [lastX, lastY] = lastPointerPos.current;
      const deltaX = event.clientX - lastX;
      const deltaY = event.clientY - lastY;

      setVelocity([deltaX * 0.02, deltaY * 0.02]);
      setPosition((prev) => [prev[0] + deltaX * 0.015, prev[1], prev[2] + deltaY * 0.015]);

      lastPointerPos.current = [event.clientX, event.clientY];
    }
  };

// 드래그 끝 - 드래깅 상태 false로 
  const handleDragEnd = (event) => {
    setIsDragging(false);
  };

// 감속
  useFrame(() => {
    if (!isDragging && velocity[0] !== 0 && velocity[1] !== 0) {
      setPosition((prev) => [prev[0] + velocity[0], prev[1], prev[2] + velocity[1]]);

      setVelocity((prev) => [prev[0] * 0.8, prev[1] * 0.8]);

      if (Math.abs(velocity[0]) < 0.01 && Math.abs(velocity[1]) < 0.01) {
        setVelocity([0, 0]);
      }
    }
  });

  return (
    <mesh
      ref={snowballRef}
      position={position}
      scale={[scale, scale, scale]}
      onPointerOut={handleDragEnd}
      onPointerDown={handleDragStart}
      onPointerMove={handleDragging}
      onPointerUp={handleDragEnd}>
      <sphereGeometry args={[1, 32, 32]} />
      <meshStandardMaterial color='white' />
    </mesh>
  );

처음에 안굴러가는 이유는 움짤찍는 앱을 켜놓고 하니 그렇습니다..

드래그에서 이동되는 량이 조금 많은 것 같기도 합니다..  기존에는 position에서 이동량 가중치는 0.015였는데 0.005로 수정해보겠습니다.

 

조금 더 무거운 느낌이 듭니다. 더 나은 것 같네요!

 

눈 크기 키우기

굴릴 수록 눈의 크기를 키워야 눈사람을 만들 수 있습니다. 속도에 가중치를 적용해주는 것과 유사하게 기본 크기에 이동량만큼을 곱해서 갱신합니다. 드래깅 되고 있을 때 크기가 커지도록 합니다. 사실 감속(useFrame)하면서도 커지는 것이 맞지만, 너무 디테일한 부분이므로 나중에 적용하려고 합니다.

const moveAmount = Math.abs(deltaX) + Math.abs(deltaY);
setScale((prev) => prev + moveAmount * 0.0001);

0.0001이라는 수치를 조절해 눈사람을 빨리 커지게할 지 느리게 커지게할 지 조절합니다. 

 

텍스처가 없어서 눈덩이같진 않긴 한데.. 이제 눈덩이는 굴릴 수록 커지게 되었습니다.

 

눈을 굴리기

텍스처를 넣고 회전을 주어 눈이 굴러가는 느낌을 더 줄 수 있도록 개선해보려고 합니다.

 

회전을 위해 쿼터니언을 사용해야하는데요. 아주 간단하게 회전 축과 회전 각도를 계산하기만 하면 회전시킬 수 있습니다.

 

만약 오른쪽 아래로 눈을 굴린다고 하면 회전 축은 이 방향과 90도를 이루어야 합니다.

회전 축 벡터는 이동 방향과 수직이기 때문의 내적값이 0이 되어야 합니다. 지금 눈덩이는 xz평면에 존재하기 때문에 이동방향 벡터는 (deltaX, 0, deltaY)입니다. 이 값과 수직하려면 (deltaY, 0, -deltaX) 또는 (-deltaY, 0, deltaX) 중 하나가 회전축이 됩니다.

 

회전축은 오른손의 법칙을 따라 회전하기 때문에 하나의 벡터는 역으로 굴러가는 모양새가 됩니다. 그림을 보면 O 쪽의 축을 선택해야 올바르게 굴러갑니다. 그림을 보면 +z로의 이동량이 +x에 적용되어 있고, +x로의 이동량이 -z에 적용되어 있습니다. 따라서 올바른 회전은 z에 -를 적용해야 합니다.

// onDragging

const axis = new Vector3(deltaY, 0, -deltaX).normalize();
const angle = (moveAmount / scale) * 0.01; 

const quaternion = new Quaternion();
quaternion.setFromAxisAngle(axis, angle);
snowballRef.current.quaternion.multiplyQuaternions(quaternion, snowballRef.current.quaternion);

angle에서는 마찬가지로 마우스 이동량과 three이동량의 차이를 보정해주기 위해 0.01정도를 줄여주었습니다.

쿼터니언을 사용할 때는 vector로 축을 만든 후 정규화를 해야한다는 걸 잊지 마세요!

 

 

임시로 텍스처를 적용하여 굴러가는 모습을 더 잘 볼 수 있도록 했습니다. 굴리는 방향대로 실제 현실과 비슷하게 잘 굴러가네요.

다만 감속인 useFrame에는 적용하지 않아 뭔가 어색합니다.

 

useFrame에서는 마우스 이동량이 아닌 실제 position이동량을 기준으로 moveAmount를 계산합니다. 0.01같은 값을 곱해줄 필요가 없다는 뜻입니다. 곱하는 값만 없앤 채로 useFrame에도 적용해주었습니다.

 

그림자

directionLight를 사용하고 그림자를 주는 옵션과 받는 옵션인 castShadow, receiveShadow를 사용하면 됩니다.

light에는 기본적으로 castShadow를 주는 것 같습니다. 캔버스에 있는 눈은 눈바닥에 그림자를 주는 친구고, 눈바닥은 이 눈의 그림자를 받는 친구이니 각각 castShadow, recieveShadow를 줍니다. 아래처럼 사용하며 됩니다. 

<주는친구 castShadow/>

<받는친구 receiveShadow/>

 

그런데 그림자가 잘립니다. 

 

이유를 찾아보니 그림자를 주는 영역이 제한되어 있는 것 같습니다. 

그래서 그림자 렌더 영역인 보라색 박스를 키워야 그림자를 더 넓은 영역에서 렌더링할 수 있습니다.

다만 이렇게 영역을 키우면 그에 맞게 그림자 품질도 떨어집니다. 이걸 높은 값으로 지정하는 옵션도 함께 사용합니다.

<directionalLight
        shadow-camera-top={25} // 그림자 렌더 영역 확장
        shadow-camera-bottom={-25} 
        shadow-camera-left={-25}
        shadow-camera-right={25}
        shadow-mapSize={2048} // 그림자 품질 향상 (기본이 512)
        castShadow
        position={[-1, 100, 10]} // 조명 위치
        intensity={1} // 조명 강도
      />

 

이제 어디로 굴려도 그림자가 잘리는 일 없이 보여집니다.

 

약간의 카메라 무빙

아래 사이트의 CameraRig를 약간 응용하여 카메라 움직임을 추가하였습니다.

https://codesandbox.io/p/sandbox/bst0cy?file=%2Fsrc%2FApp.js%3A66%2C1-71%2C2

좀 어색하지만 임시로 눈바닥도 텍스처를 적용해주었습니다.

 

 

이펙트

이대로면 너무 칙칙하니까 좀 더 눈같이 이펙트를 주었습니다. 이펙트는 postprocessing라이브러리를 사용합니다

<EffectComposer>
  <Bloom kernelSize={3} luminanceThreshold={0} luminanceSmoothing={0.1} intensity={0.8} />
</EffectComposer>

 

2편에서는 다음 내용을 구현하도록 하겠습니다.
- 눈덩이 실제 모델 사용

- 눈 굴리는 자국

- 흩날리는 눈

- 눈 사람 두개 붙이기

그런데.. 모니터 해상도에 따라서 이동량의 변화가 매우 큰 것으로 확인되었습니다. 현재 브라우저의 크기와 이동량의 비율을 따져 눈덩이의 이동량을 계산해야할 것 같네요.