Hello Kitty Eyes Shut
λ³Έλ¬Έ λ°”λ‘œκ°€κΈ°

πŸ’»κ³΅λΆ€ 기둝/πŸ“Œ Frontend

[Frontend] 3D 둜고 νŽ˜μ΄λ“œμΈ μ΅œμ ν™”

λ°˜μ‘ν˜•

 

 

 

 

πŸ“‘ λ“€μ–΄κ°€λ©°

λ‚˜λŠ” μ§€κΈˆ μŠ€ν¬λ‘€μ„ λ‚΄λ¦¬λ©΄μ„œ λ‚΄ μ†Œκ°œμ™€ ν”„λ‘œμ νŠΈ 등을 λ³Ό 수 μžˆλŠ”
개인 포트폴리였 μ‚¬μ΄νŠΈλ₯Ό λ§Œλ“œλŠ” 쀑이닀.

 

κ·Έμ€‘μ—μ„œλ„ κ°€μž₯ 첫 번째 μ„Ήμ…˜μΈ νžˆμ–΄λ‘œ μ„Ήμ…˜μ—μ„œλŠ”
이전에 λΈ”λ Œλ”λ‘œ 3D λͺ¨λΈλ§ν–ˆλ˜ λ‚΄ 이름 둜고λ₯Ό λ Œλ”λ§ν•΄μ„œ 보여주고 μ‹Άμ—ˆλ‹€ 🀧

 

그리고 μ΄λ•Œ, λ‹¨μˆœνžˆ 3D λͺ¨λΈμ„ 올리고 λλ‚˜λŠ” 것이 μ•„λ‹ˆλΌ,

κΈ°λ³Έμ μœΌλ‘œλŠ” μžλ™μœΌλ‘œ 살짝살짝 νšŒμ „μ„ ν•˜κ³  있되,

μ‚¬μš©μžκ°€ 마우슀둜 λ“œλž˜κ·Έν•˜λ©΄, κ·Έ νšŒμ „μ„ μ‚¬μš©μž μž…λ ₯에 따라 λ°˜μ˜ν•˜κ³ ,

마우슀λ₯Ό λ†“λŠ” μˆœκ°„, κ·Έ μƒνƒœλ₯Ό κΈ°μ€€μœΌλ‘œ λ‹€μ‹œ μžλ™ νšŒμ „μ΄ μ΄μ–΄μ§€λŠ” 것을 μ›ν–ˆλ‹€.

 

λ˜ν•œ, μ²˜μŒμ—” μ‚¬μ΄νŠΈλ₯Ό λ“€μ–΄μ˜€λŠ” μˆœκ°„ λ‘œκ³ κ°€ λ°”λ‘œ λ“±μž₯ν•˜κ²Œ ν–ˆλ”λ‹ˆ λ„ˆλ¬΄ λΆ€μžμ—°μŠ€λŸ¬μ›Œμ„œ

μžμ—°μŠ€λŸ¬μ›€μ„ μœ„ν•΄ λΆ€λ“œλŸ½κ²Œ νŽ˜μ΄λ“œμΈ λ˜λ©΄μ„œ λ“±μž₯ν•  수 있게 λ§Œλ“€κ³  μ‹Άμ—ˆλ‹€.

 

 

κ·Έλž˜μ„œ 이번 ν¬μŠ€νŒ…μ—μ„œλŠ” 일단 νŽ˜μ΄λ“œμΈμ„ μ–΄λ–»κ²Œ κ΅¬ν˜„ν–ˆκ³ ,

μ–΄λ–»κ²Œ μ΅œμ ν™”ν–ˆλŠ”μ§€λ₯Ό 정리해보렀고 ν•œλ‹€.

 

 


πŸŸ₯ 초기 νŽ˜μ΄λ“œμΈ μ½”λ“œ

처음 νŽ˜μ΄λ“œμΈμ„ κ΅¬ν˜„ν–ˆμ„ 땐 정말 ,, μ§κ΄€μ μœΌλ‘œ μ½”λ“œλ₯Ό μ§°λ‹€.

 

λΆ€λ„λŸ½μ§€λ§Œ ,, μ•„λž˜ μ½”λ“œμ—μ„œ λ³Ό 수 μžˆλ“―μ΄

μ²˜μŒμ—λŠ” 재질의 opacityλ₯Ό 0으둜 λ§Œλ“€μ–΄μ„œ μ•ˆ 보이게 ν–ˆλ‹€κ°€

λ§€ ν”„λ ˆμž„λ§ˆλ‹€ opacityλ₯Ό μ‘°κΈˆμ”© μ¦κ°€μ‹œμΌœμ„œ 1에 κ°€κΉŒμ›Œμ§€λ„λ‘ λ§Œλ“€μ—ˆλ‹€ ,, πŸ˜…

useEffect(() => {
  scene.traverse((obj) => {
    if (obj instanceof Mesh && obj.material instanceof MeshStandardMaterial) {
      obj.material.transparent = true;
      obj.material.opacity = 0;
    }
  });
}, [scene]);

useFrame(() => {
  scene.traverse((obj) => {
    if (obj instanceof Mesh && obj.material instanceof MeshStandardMaterial) {
      if (obj.material.opacity < 1) {
        obj.material.opacity += 0.01;
      }
    }
  });
});

 

μ†”μ§νžˆ μ΄λ•ŒκΉŒμ§€λ§Œ 해도 '였 ! λ‚΄κ°€ μ›ν•œλŒ€λ‘œ 잘 λ‚˜νƒ€λ‚˜λŠ”κ΅° !! 생각보닀 쉽넀 !? 😏' ν–ˆλ‹€.

 

ν•˜μ§€λ§Œ λ‚˜μ€‘μ— R3F와 Three.js κ΄€λ ¨ 글듀을 μ’€ 더 μ°Ύμ•„λ³΄λ‹ˆ

이 μ½”λ“œμ—λŠ” 두 κ°€μ§€μ˜ 큰 λ¬Έμ œκ°€ μžˆλ‹€λŠ” 사싀을 κΉ¨λ‹«κ²Œ λ˜μ—ˆλ‹€.

 

 

문제점 1) λ§€ ν”„λ ˆμž„λ§ˆλ‹€ scene.traverseλ₯Ό ν˜ΈμΆœν•˜κ³  있음

첫 번째 λ¬Έμ œλŠ” μ„±λŠ₯ λΆ€λΆ„μ΄μ—ˆλ‹€.

useFrame(() => {
  scene.traverse((obj) => {
    ...
  });
});

 

κΈ°μ‘΄ μ½”λ“œμ— 있던 이 뢀뢄을 λ‹€μ‹œ 보면, λ§€ ν”„λ ˆμž„λ§ˆλ‹€ μ‹€ν–‰λ˜λŠ” 것을 μ•Œ 수 μžˆλ‹€.

일반적으둜 μ΄ˆλ‹Ή 60ν”„λ ˆμž„ μ •λ„λ‘œ 돌고 μžˆλ‹€κ³  치면, 1μ΄ˆμ— 60번 scene.traverseλ₯Ό ν˜ΈμΆœν•˜κ³  μžˆλŠ” 것이닀..

 

뭐 scene.traverseκ°€ λ‹¨μˆœν•œ ν•¨μˆ˜μ΄λ©΄ λͺ¨λ₯ΌκΉŒ

이 ν•¨μˆ˜λŠ” scene μ•ˆμ— μžˆλŠ” λͺ¨λ“  obejectλ₯Ό μž¬κ·€μ μœΌλ‘œ ν›‘λŠ” ν•¨μˆ˜λΌλŠ”κ²Œ λ¬Έμ œμ˜€λ‹€.

 

예λ₯Ό λ“€μ–΄ GLTF λͺ¨λΈ μ•ˆμ— Meshκ°€ 10개이면 10개λ₯Ό λ§€ ν”„λ ˆμž„λ§ˆλ‹€ νƒμƒ‰ν•˜λŠ” κ±°κ³ ,

100개면 100개λ₯Ό λ§€ ν”„λ ˆμž„λ§ˆλ‹€ νƒμƒ‰ν•˜λŠ” 것이닀.

 

λ”°λΌμ„œ opacityλ₯Ό μ•½κ°„ 올렀주고 μ‹Άλ‹€λŠ” 이유만으둜 λ§€ ν”„λ ˆμž„ 전체 ꡬ쑰λ₯Ό ν›‘κ³  μžˆλ‹€λŠ” 게 λ„ˆλ¬΄ λΉ„νš¨μœ¨μ μΈ 것 κ°™μ•„μ„œ

이 뢀뢄에 λŒ€ν•΄μ„œ κ°œμ„ μ„ ν•  ν•„μš”κ°€ μžˆλ‹€κ³  μƒκ°ν–ˆλ‹€.

 

πŸ‘‰πŸ» κ·Έλž˜μ„œ λ‚΄κ°€ μƒκ°ν•œ 이 문제의 해결법은 'mount될 λ•Œ ν•œ 번만 traverse ν•˜λ„λ‘ ν•˜μž'μ˜€λ‹€.

 

 

문제점 2) ν”„λ ˆμž„ 의쑴적인 μ• λ‹ˆλ©”μ΄μ…˜

두 번째 λ¬Έμ œλŠ” μ• λ‹ˆλ©”μ΄μ…˜μ˜ μ†λ„μ˜€λ‹€.

if (obj.material.opacity < 1) {
  obj.material.opacity += 0.01;
}

 

이 방식은 κ²°κ΅­ ν•œ ν”„λ ˆμž„μ— opacityλ₯Ό 0.01μ”© μ¦κ°€ν•˜λŠ” 것인데, ν”„λ ˆμž„ μˆ˜λŠ” ν™˜κ²½λ§ˆλ‹€ λ‹€λ₯΄λ‹€λŠ” 것이 λ¬Έμ œμ˜€λ‹€.

 

μ–΄λ–€ κΈ°κΈ°μ—μ„œλŠ” 60fps일 μˆ˜λ„ μžˆμ§€λ§Œ, μ–΄λ–€ ν™˜κ²½μ—μ„œλŠ” 30fps μ΄ν•˜μΌ μˆ˜λ„ 있고,

λΈŒλΌμš°μ € 탭이 λ°±κ·ΈλΌμš΄λ“œλ‘œ κ°€λ©° ν”„λ ˆμž„ λ ˆμ΄νŠΈκ°€ λ–¨μ–΄μ§ˆ μˆ˜λ„ 있기 λ•Œλ¬Έμ—

60fpsμ—μ„œλŠ” 더 빨리 νŽ˜μ΄λ“œμΈ 되고, 30fpsμ—μ„œλŠ” 두 λ°° 느리게 νŽ˜μ΄λ“œμΈ λ˜λŠ” λ“±

κΈ°κΈ° μ„±λŠ₯에 따라 μ• λ‹ˆλ©”μ΄μ…˜μ˜ 길이가 λ‹¬λΌμ§€λŠ” ν˜„μƒμ΄ 생길 수 μžˆλ‹€.

 

πŸ‘‰πŸ» λ”°λΌμ„œ ν•΄κ²°μ±…μœΌλ‘œ 이 ꡬ쑰λ₯Ό ν”„λ ˆμž„ μˆ˜κ°€ μ•„λ‹Œ μ‹œκ°„μ„ κΈ°μ€€μœΌλ‘œ 움직이도둝 μˆ˜μ •ν•˜κ³ μž ν–ˆλ‹€.

 

 


🟧 μˆ˜μ • κ³Όμ •

μœ„μ—μ„œ μž‘μ€ μˆ˜μ • λͺ©ν‘œλ₯Ό 정리해보면 μ•„λž˜μ™€ κ°™λ‹€.

  • scene.traverseλŠ” λ”± ν•œ 번만 ν•œλ‹€.
  • μ• λ‹ˆλ©”μ΄μ…˜μ€ ν”„λ ˆμž„ μˆ˜κ°€ μ•„λ‹ˆλΌ μ‹œκ°„μ„ κΈ°μ€€μœΌλ‘œ μ›€μ§μ΄κ²Œ ν•œλ‹€.

 

그리고, 이λ₯Ό μœ„ν•΄ κ°€μž₯ λ¨Όμ € ν•œ μž‘μ—…μ΄ μž¬μ§ˆμ„ ν•œ 번만 λͺ¨μ•„λ‘κ²Œ ν•˜λŠ” κ²ƒμ΄μ—ˆλ‹€.

μ™œλƒλ©΄ 맀번 traverseλ₯Ό λ„λŠ” μ΄μœ κ°€ κ²°κ΅­ μ–΄λ–€ mesh의 μ–΄λ–€ material에 opacityλ₯Ό μ μš©ν•΄μ•Ό ν•˜λŠ”μ§€λ₯Ό μ°ΎκΈ° μœ„ν•¨μ΄κΈ° λ•Œλ¬Έμ΄λ‹€.

 

그렇기에 ν•œ 번만 μ°Ύμ•„μ„œ μ €μž₯해두고, κ·Έ μ΄ν›„μ—λŠ” κ·Έ λͺ©λ‘λ§Œ 돌면 이 문제λ₯Ό ν•΄κ²°ν•  수 μžˆμ„ 거라 μƒκ°ν–ˆκ³ ,

λ”°λΌμ„œ μ•„λž˜μ™€ 같이 νŽ˜μ΄λ“œμΈ λŒ€μƒμ΄ λ˜λŠ” μž¬μ§ˆλ“€μ„ λͺ¨μ•„두기 μœ„ν•œ refλ₯Ό μΆ”κ°€ν•΄μ£Όμ—ˆλ‹€.

const materialsRef = useRef<MeshStandardMaterial[]>([]);

 

 

그리고, useEffectμ—μ„œ 마운트 μ‹œ λ”± ν•œ 번만 scene.traverseλ₯Ό λŒλ„λ‘ μˆ˜μ •ν•΄μ£Όμ—ˆλ‹€.

useEffect(() => {
  const collected: MeshStandardMaterial[] = [];

  scene.traverse((obj) => {
    if (obj instanceof Mesh) {
      obj.castShadow = true;
      obj.receiveShadow = false;
    }

    if (isMeshWithStandardMaterial(obj)) {
      const material = obj.material;
      material.transparent = true;
      material.opacity = 0;
      material.needsUpdate = true;
      collected.push(material);
    }
  });

  materialsRef.current = collected;
}, [scene]);

 

μ—¬κΈ°μ„œ ν•˜λŠ” 일은 크게 두 가지이닀.

  • 그림자 μ„€μ •
    • obj.castShadoe = true;
    • obj.receiveShadow = false;
  • νŽ˜μ΄λ“œμΈ λŒ€μƒ 재질 μˆ˜μ§‘
    • MeshStandardMaterial νƒ€μž…μ˜ 재질만 κ³¨λΌμ„œ
    • transparent = true, opacity = 0으둜 μ΄ˆκΈ°ν™”ν•˜κ³ 
    • materialsRef.current 배열에 λͺ¨μ•„λ‘”λ‹€.

μ΄λ ‡κ²Œ ν•΄μ€ŒμœΌλ‘œμ¨ 이 μž‘μ—…μ€ 마운트 타이밍에 λ”± ν•œ 번만 μΌμ–΄λ‚˜κ²Œ λœλ‹€.

 

이후 useFrameμ—μ„œλŠ” 더이상 scene.traverseλ₯Ό ν˜ΈμΆœν•  ν•„μš”κ°€ μ—†κ³ ,

κ·Έ λŒ€μ‹  materialsRef.current 배열을 λŒλ©΄μ„œ opacity κ°’λ§Œ μ—…λ°μ΄νŠΈν•˜λ©΄ λœλ‹€.

 

 

μ΄λ ‡κ²Œ μˆ˜μ •ν•¨μœΌλ‘œμ¨ GLTFκ°€ 아무리 λ³΅μž‘ν•΄μ Έλ„,

λ§€ ν”„λ ˆμž„ λΉ„μš©μ€ 재질의 개수 만큼만 λŠ˜μ–΄λ‚˜κ²Œ μ œν•œν•  수 μžˆλ„λ‘ μ΅œμ ν™”κ°€ 된 것이닀 !

 

 


 

 

λ‹€μŒμœΌλ‘œ 고친 뢀뢄은 opacity 증가 λ‘œμ§μ΄λ‹€.

 

κΈ°μ‘΄μ—λŠ” μ•„λž˜μ™€ 같이 ν”„λ ˆμž„λ§ˆλ‹€ 0.01μ”© μ¦κ°€μ‹œν‚€κ³  μžˆμ—ˆλ‹€λ©΄,

opacity += 0.01;

 

μ΄μ œλŠ” μˆ˜μ • λͺ©ν‘œμ— 따라 'λͺ‡ 초 λ™μ•ˆ 0μ—μ„œ 1κΉŒμ§€ λ°”κΏ€ 것인지'λ₯Ό κΈ°μ€€μœΌλ‘œ μ œμ–΄ν•  수 μžˆλ„λ‘ 고치고 μ‹Άμ—ˆλ‹€.

 

κ·Έλž˜μ„œ λ– μ˜¬λ¦° 아이디어가 'νŽ˜μ΄λ“œμΈμ΄ μ‹œμž‘λ˜λŠ” μ‹œκ°μ„ 기얡해두고,

λ§€ ν”„λ ˆμž„λ§ˆλ‹€ `ν˜„μž¬ μ‹œκ° - μ‹œμž‘ μ‹œκ°`을 ν•΄μ£Όλ©΄, 그게 κ³§ κ²½κ³Ό μ‹œκ°„μž„μ„ μ΄μš©ν•˜μž' μ˜€λ‹€.

 

λ”°λΌμ„œ 이λ₯Ό μœ„ν•΄ νŽ˜μ΄λ“œμΈμ˜ μ‹œμž‘ μ‹œμ μ˜ μ‹œκ°„μ„ μ €μž₯ν•  μ•„λž˜μ˜ refλ₯Ό ν•˜λ‚˜ 더 μΆ”κ°€ν•΄μ£Όμ—ˆλ‹€.

const startTimeRef = useRef<number | null>(null);

 

그리고 useFrameμ—μ„œλŠ” R3Fκ°€ μ œκ³΅ν•˜λŠ” clock을 μ΄μš©ν•΄μ„œ μ•„λž˜μ™€ 같이 λ§Œλ“€μ–΄μ£Όμ—ˆλ‹€.

useFrame(({ clock }) => {
  if (materialsRef.current.length === 0) return;

  if (startTimeRef.current === null) {
    startTimeRef.current = clock.getElapsedTime();
  }

  const elapsed = clock.getElapsedTime() - startTimeRef.current;
  const rawT = Math.min(1, elapsed / fadeDuration); // 0~1
  const t = rawT * rawT * (3 - 2 * rawT); // λΆ€λ“œλŸ¬μš΄ 이징

 

μ—¬κΈ°μ—μ„œ clock.getElapsedTime()은 씬이 μ‹œμž‘λœ ν›„ 흐λ₯Έ μ‹œκ°„(초 λ‹¨μœ„)이고,

startTimeRef.currentμ—λŠ” νŽ˜μ΄λ“œμΈ μ‹œμž‘ μ‹œμ μ˜ μ‹œκ°„μ„ μ €μž₯ν•΄μ£Όμ—ˆλ‹€.

그리고 elapsedμ—λŠ” μ‹œμž‘ ν›„ μ–Όλ§ˆλ‚˜ μ§€λ‚¬λŠ”μ§€λ₯Ό μ €μž₯ν–ˆμœΌλ©°,

elapsed / fadeDuration을 톡해 0 ~ 1 μ‚¬μ΄μ˜ λΉ„μœ¨λ‘œ μ •κ·œν™”ν•΄μ£Όμ—ˆλ‹€.

(예λ₯Ό λ“€μ–΄μ„œ fadeDuration = 2.5라 κ°€μ •ν•˜λ©΄, 0초일 땐 0, 1.25초일 땐 0.5, 2.5초 이상일 땐 1이 λ˜λŠ” λŠλ‚Œ)

 

λ˜ν•œ, μ•„λž˜μ˜ 식을 μ μš©ν•΄μ„œ 0μ—μ„œ 1둜 갈 λ•Œ 처음과 끝이 μ’€ 더 λΆ€λ“œλŸ¬μš΄ S-곑선을 λ§Œλ“€μ–΄μ£Όλ„λ‘ ν•˜μ˜€λ‹€.

(μΌμ’…μ˜ smoothstep ν˜•νƒœλΌκ³  보면 λœλ‹€.)

const t = rawT * rawT * (3 - 2 * rawT);

 

그리고 μ΄λ ‡κ²Œ 계산해낸 tλ₯Ό opacity에 κ·ΈλŒ€λ‘œ λ°˜μ˜ν•΄μ£Όμ—ˆλ‹€.

materialsRef.current.forEach((mat) => {
  mat.opacity = t;
  mat.needsUpdate = true;
});

 

μ΄λ ‡κ²Œ ν•΄μ€ŒμœΌλ‘œμ¨ μ΄μ œλŠ” ν”„λ ˆμž„ μˆ˜μ— 상관 없이 항상 같은 μ‹œκ°„ λ™μ•ˆ νŽ˜μ΄λ“œμΈμ΄ μ§„ν–‰λ˜κ³ ,

값도 0 ~ 1 사이λ₯Ό S-곑선 ν˜•νƒœλ‘œ λΆ€λ“œλŸ½κ²Œ μ±„μ›Œμ§€λ„λ‘ λ˜μ—ˆλ‹€.

 

 


 

 

λ˜ν•œ λ§ˆμ§€λ§‰μœΌλ‘œλŠ”, μ–΄μ°¨ν”Ό opacityκ°€ 1이 된 μ΄ν›„μ—λŠ” 더이상 계속 useFrameμ—μ„œ μ²˜λ¦¬ν•  ν•„μš”κ°€ μ—†λ‹€κ³  μƒκ°ν•΄μ„œ

νŽ˜μ΄λ“œμΈμ΄ λλ‚¬μŒμ„ λ‚˜νƒ€λ‚΄λŠ” ν”Œλž˜κ·Έλ₯Ό ν•˜λ‚˜ 두고, κ·Έ μ΄ν›„μ—λŠ” useFrameμ—μ„œ 콜백으둜 λ°”λ‘œ return 해버리도둝 λ§Œλ“€μ—ˆλ‹€.

(μœ„μ— κΉŒμ§€λ§Œ μˆ˜μ •ν–ˆμ„ λ•Œμ˜ κ΅¬μ‘°μ—μ„œλŠ” rawTκ°€ 1에 λ„λ‹¬ν•œ 이후에도 계속 같은 값을 λ‹€μ‹œ opacity에 λ„£κ³  μžˆμ—ˆλ‹€.)

const doneRef = useRef(false);
if (rawT >= 1) {
  doneRef.current = true;
}

if (doneRef.current) return;

 

μ΄λ ‡κ²Œ μˆ˜μ •ν•¨μœΌλ‘œμ¨ rawTκ°€ 1에 λ„λ‹¬ν•˜λŠ” μˆœκ°„ doneRef.current = true둜 λ³€κ²½ν•˜κ³ ,

κ·Έ 이후 ν”„λ ˆμž„λΆ€ν„°λŠ” if (doneRef.current) return; 에 κ±Έλ €μ„œ early returnλœλ‹€.

 

즉, νŽ˜μ΄λ“œμΈ μ• λ‹ˆλ©”μ΄μ…˜μ΄ λλ‚œ μ‹œμ λΆ€ν„°λŠ” 사싀상 이 μ»΄ν¬λ„ŒνŠΈλŠ” useFrameμ—μ„œ 아무것도 ν•˜μ§€ μ•ŠλŠ” μƒνƒœκ°€ λœλ‹€.

 

λ”°λΌμ„œ μž₯기적으둜 보면,

이 νŽ˜μ΄μ§€λ₯Ό 계속 μΌœλ‘κ³  μž‘μ—…ν•˜κ±°λ‚˜ 탭이 μ˜€λž«λ™μ•ˆ μ—΄λ € μžˆλŠ” μƒν™©μ—μ„œλ„ λΆˆν•„μš”ν•œ 연산을 μ΅œμ†Œν™”ν•œ ꡬ쑰가 λœλ‹€.

 

 


🟨 κ²°κ³Ό ν™”λ©΄

 

λ°˜μ‘ν˜•