lusion 에서 THREE.JS로 마우스를 따라다니는 인스턴스를 구현한것이 있어서 이것에 영감을 받아 만들었습니다. 여기에 THREE.js, React-Three-Fiber, React-Three-Drei 가 사용되었습니다.
function MouseFollower({ vec = new THREE.Vector3(), instanceCount = 90 })
먼저 입력값을 받는 파라미터는 x,y,z 위치를 결정하는 vector3 와 인스턴스의 개수입니다.
const swarmTextures = {
basecolor: useLoader(THREE.TextureLoader, '/textures/swarm/Glass_Window_003_basecolor.jpg'),
normalMap: useLoader(THREE.TextureLoader, '/textures/swarm/Glass_Window_003_normal.jpg'),
heightMap: useLoader(THREE.TextureLoader, '/textures/swarm/Glass_Window_003_height.png'),
roughnessMap: useLoader(THREE.TextureLoader, '/textures/swarm/Glass_Window_003_roughness.jpg'),
aoMap: useLoader(THREE.TextureLoader, '/textures/swarm/Glass_Window_003_ambientOcclusion.jpg'),
metallicMap: useLoader(THREE.TextureLoader, '/textures/swarm/Glass_Window_003_metallic.jpg'),
};
그다음 마우스 주변에 texture를 로드하는데, texture가 없어도 상관은 없습니다.
const { MouseFollowerHandler } = useStore();
const MouseGroup = useRef();
const light = useRef();
const lightSphereRef = useRef();
const { camera, viewport } = useThree((state) => ({ camera: state.camera, viewport: state.viewport }));
const mouseInstances = useRef([]);
const lensFlareMaterialRef = useRef();
마우스에 여러가지 Ref 값들을 선언합니다. 순서대로
MouseFollowerHandler : Zustand 라이브러리를 통해 위의 인스턴스를 작동시킬지 여부입니다. 리덕스와 기능은 같지만 Zustand와 React-Three-Drei를 같은 회사에서 만든것이라 그런지 서로 호환이 좋다고 소개되어있어서 이것을 사용했습니다. 이 부분은 특정 페이지에서 인스턴스를 작동시키는게 목적이라 처음부터 위의 기능을 사용하는것이라면 굳이 사용하지 않아도 되는 것들입니다.
MouseGroup : 맨 처음 gif 파일을 보면, 마우스 주변을 따라다니는 인스턴스와, 마우스 위치에서 고정되어있는 Geometry, 마우스 위치에 고정된 Light 3가지로 구성되어있는데 이것을 묶어주는 GroupRef입니다.
light : 마우스 위치에 고정된 Light Ref입니다.
lightSphereRef : 마우스 위치에 고정된 Geometry Ref 입니다.
camera, viewport : 전체화면의 크기를 계산하기 위한 THREE.js stdlib 입니다.
mouseInstances : 마우스를 따라다니는 인스턴스 Ref 입니다.
lensFlareMaterialRef : 초기에는 lightSphereRef
에 Custom Material을 적용시키려고 Ref 값을 주었으나, EffectComposer 사용으로 변경했고 지금은 사용되지 않습니다.
const initialPositions = useMemo(
() =>
Array.from({ length: instanceCount }, () => ({
position: new THREE.Vector3((Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2),
velocity: new THREE.Vector3(
(Math.random() - 0.5) * 0.04,
(Math.random() - 0.5) * 0.04,
(Math.random() - 0.5) * 0.04
),
})),
[instanceCount]
);
초기 인스턴스들의 위치값들입니다. 위치와 속도값은 순서대로 x,y,z 에 할당됩니다.
useEffect(() => {
if (MouseFollowerHandler) {
mouseInstances.current.forEach((instance) => {
gsap.to(instance.scale, {
x: 1,
y: 1,
z: 1,
duration: 1,
ease: 'power2.out',
});
});
gsap.to(lightSphereRef.current.scale, {
x: 1,
y: 1,
z: 1,
duration: 1,
ease: 'power2.out',
});
} else {
mouseInstances.current.forEach((instance) => {
gsap.to(instance.scale, {
x: 0,
y: 0,
z: 0,
duration: 1,
ease: 'power2.out',
});
});
gsap.to(lightSphereRef.current.scale, {
x: 0,
y: 0,
z: 0,
duration: 1,
ease: 'power2.out',
});
}
}, [MouseFollowerHandler]);
이 부분역시 MouseFollowerHandler
와 마찬가지로 특정 페이지에서 인스턴스들을 on/off 시키는 기능입니다. 처음부터 사용하면 위의 함수는 사용하지 않습니다.
useFrame((state) => {
const { pointer } = state;
// 페이지 수가 늘어나면 그에맞춰서 추가
const x = (pointer.x * viewport.width) / 6;
const y = (pointer.y * viewport.height) / 6;
MouseGroup.current.position.lerp(vec.set(x, y, 0), 0.05);
light.current.position.lerp(vec.set(x, y, 1), 0.05);
mouseInstances.current.forEach((instance, i) => {
const { position, velocity } = initialPositions[i];
// Add velocity to position (simulate movement)
position.add(velocity);
// Reverse velocity if position goes out of bounds
if (position.x > 1.2 || position.x < -1.2) velocity.x = -velocity.x;
if (position.y > 1.2 || position.y < -1.2) velocity.y = -velocity.y;
if (position.z > 1.2 || position.z < -1.2) velocity.z = -velocity.z;
// Calculate target position relative to the group considering mouse pointer
const targetPosition = position.clone().add(vec.set(x / (Math.PI * 15), y / (Math.PI * 15), 0));
// Smoothly move instance towards the target position using lerp
instance.position.lerp(targetPosition, 0.1);
});
});
실질적으로 인스턴스와 빛, 마우스의 geometry가 움직이는 함수입니다.
const x = (pointer.x * viewport.width) / 6;
const y = (pointer.y * viewport.height) / 6;
이 부분은 react-three-drei의 ScrollContoller
를 사용하여 화면을 확장시켰기때문에 그에 맞춰 화면을 분할한거고 만약 단일 페이지에서 실행시킨다면 아래와 같이 사용하셔야합니다.
const x = (pointer.x * viewport.width);
const y = (pointer.y * viewport.height);
if (position.x > 1.2 || position.x < -1.2) velocity.x = -velocity.x;
if (position.y > 1.2 || position.y < -1.2) velocity.y = -velocity.y;
if (position.z > 1.2 || position.z < -1.2) velocity.z = -velocity.z;
이 부분은 인스턴스가 너무 멀리 떨어지지않도록 특정 position을 초과하면 다시 돌아오게끔 설정하였습니다.
그 외에 거의 모든 부분에 lerp
을 넣어서 보간하였는데 다음과 같은 공식을 사용합니다.
return (
<>
<group ref={MouseGroup}>
<Sphere args={[0.025, 32, 32]} ref={lightSphereRef} scale={0}>
<meshStandardMaterial color="white" roughness={1} ref={lensFlareMaterialRef} />
</Sphere>
<pointLight ref={light} position={[0, 0, 1]} intensity={5} color={'white'} />
<spotLight ref={light} position={[0, 0, 1]} angle={0.15} penumbra={3} />
{Array.from({ length: instanceCount }).map((_, i) => (
<instancedMesh ref={(ref) => (mouseInstances.current[i] = ref)} key={i} args={[null, null, 1]}>
<dodecahedronGeometry args={[0.05, 0]} />
<meshStandardMaterial
map={swarmTextures.basecolor}
normalMap={swarmTextures.normalMap}
roughnessMap={swarmTextures.roughnessMap}
metalnessMap={swarmTextures.metallicMap}
aoMap={swarmTextures.aoMap}
aoMapIntensity={0.1}
color={'white'}
roughness={0.2}
metalness={0.4}
/>
</instancedMesh>
))}
</group>
<EffectComposer>
<SelectiveBloom
lights={[light]}
selection={[lightSphereRef]}
intensity={1.0}
luminanceThreshold={0.9}
luminanceSmoothing={0.025}
/>
</EffectComposer>
</>
);
}
마지막 return 부분에 할당된 geometry, material, instance 들을 설정해주면 완성입니다.
react-three-drei의 Instance
가 지원되는데 react-three-fiber의 instancedMesh
를 사용한 이유는, Instance
를 사용하면 퍼포먼스가 크게 떨어지고 메모리 누수가 발생합니다. 웬만한 대량 인스턴스를 처리할때에는 instanceMesh
를 사용하는것을 추천합니다.