[React + three.js] React์—์„œ raycaster ์ž˜ ์จ๋ณด๊ธฐ

2022. 12. 19. 19:17ใ†์›น/Three.js

๐Ÿ”ฆraycaster์˜ ์—ญํ• ๊ณผ ๋ฐฐ๊ฒฝ


three.js๋ฅผ ์‚ฌ์šฉํ•˜๋‹ค๋ณด๋ฉด ๊ฐ Mesh์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ๋„ฃ์–ด์ฃผ๊ณ  ์‹ถ์€ ์ผ๋“ค์ด ์ƒ๊ธด๋‹ค.

(๋ˆ„๋ฅด๋ฉด ๊ด€๋ จ ์›นํŽ˜์ด์ง€๋กœ ๋„˜๊ธฐ๋˜๊ฐ€, ์•„๋‹ˆ๋ฉด ๋‚ด๋ถ€ ๋กœ์ง์„ ์œ„ํ•ด id ๊ฐ™์€ key๊ฐ’์„ ๋ฆฌํ„ดํ•ด์ฃผ๋˜๊ฐ€...)

์ด๋Ÿฐ ์ฒ˜๋ฆฌ๋ฅผ raycaster ์—์„œ ํ•ด์ฃผ๋Š”๋ฐ ์ ์šฉํ•˜๊ณ  ๋‚˜๋ฉด onClickEvent๋‚˜ onMouseOverEvent๋ฅผ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค.

 

raycaster๋Š” ๋œป ๊ทธ๋Œ€๋กœ ๊ด‘์„ ์ด๋ผ๊ณ  ์ƒ๊ฐํ•˜๋ฉด ๋œ๋‹ค. ๋งˆ์šฐ์Šค๊ฐ€ ์žˆ๋Š” ๊ฐ’ ๊ธฐ์ค€์œผ๋กœ ๊ด‘์„ ์„ ์ญ‰ ์ˆ์„ ๋•Œ, ๊ทธ ๊ด‘์„ ์„ ๋งž์€ Mesh์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ์–ป์–ด๋‚ผ ์ˆ˜ ์žˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค๋ฉด ์ด๋Ÿฐ ์‹์œผ๋กœ ๊ตฌํ˜„์ด ๊ฐ€๋Šฅํ•˜๋‹ค.

 

 

๊ตฌํ˜„์„ ์œ„ํ•ด์„  ๋ช‡ ๊ฐ€์ง€์˜ ํŠธ๋ฆญ(?)์ด ์กด์žฌํ•˜๋Š”๋ฐ ์ฃผ๋กœ ์‚ฌ์šฉ์ž ์ธํ„ฐ๋ ‰์…˜ ๊ด€๋ จ๋œ ๋‚ด์šฉ์ด๋‹ค.

๊ตฌํ˜„ ๋ชฉํ‘œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

 

1. raycaster ์ƒ์„ฑ in React.
2. ray๋ฅผ ๋งˆ์šฐ์Šค์— ๋งž์ถฐ์„œ ์˜๋Š” ๋ฐฉ๋ฒ•.
3. ๊ฐ mesh์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์‚ฝ์ž…ํ•˜๋Š” ๋ฐฉ๋ฒ•.
ETC) UI ๊ด€๋ จ (๋งˆ์šฐ์Šค ์ปค์„œ ๋ณ€๊ฒฝ์ด๋ผ๋˜๊ฐ€...)

 

 

1. raycaster ์ƒ์„ฑ in React


raycaster ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ, React์—์„œ three.js์˜ ํด๋ž˜์Šค๋“ค์„ ๋‹ค๋ฃฐ ๋•Œ ์ค‘์š”ํ•œ๊ฒŒ ๋ฆฌํŽ˜์ธํŒ…์ด ์•ˆ๋˜๊ฒŒ ์กฐ์‹ฌํ•˜๋Š” ๊ฒƒ์ด๋‹ค.

๋Œ€๋ถ€๋ถ„ ํฐ ์šฉ๋Ÿ‰์˜ ๋ฆฌ์†Œ์Šค ํŒŒ์ผ๋“ค์„ ๋ Œ๋”๋ง ํ•  ๋•Œ ์‹œ๊ฐ„์ด ๋งŽ์ด ํ•„์š”ํ•œ๋ฐ, ๋ณ„ ์ƒ๊ฐ์—†์ด state๋กœ ๊ด€๋ฆฌ๋œ๋‹ค๋ฉด init์„ ๋„์™€์ฃผ๋Š” ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋œ๋‹ค๋ฉด Mesh๊ฐ€ ๋ช‡ ๋ฒˆ ์”ฉ ๋ฆฌํŽ˜์ธํŒ… ๋  ์ˆ˜ ์žˆ๋‹ค. (CRA์˜ StrictMode๋ผ๋˜๊ฐ€..)

 

raycaster๋Š” ์ƒ์„ฑํ•˜๊ณ  ๋‚˜์„œ ์ „์—ญ์ ์ธ EventListener๋กœ ํ•ธ๋“ค๋Ÿฌ๋ฅผ ๊ณ„์† ํ˜ธ์ถœํ•ด์•ผํ•˜๋‹ˆ useRef๋กœ ์„ค์ •ํ•ด์ฃผ์ž.

 

const FrontEndPF: React.FC<FrontEndPFProps> = (props) => {
  const camera = useRef<THREE.PerspectiveCamera>(new THREE.PerspectiveCamera(80, window.innerWidth / window.innerHeight, 0.1, 10000));
  const renderer = useRef<THREE.WebGLRenderer>(new THREE.WebGLRenderer({ antialias: false, alpha: true }));
  const raycaster = useRef<THREE.Raycaster>(new THREE.Raycaster());
  //...
}

 

๋งŒ์•ฝ fog๋ฅผ ๋”ฐ๋กœ ์„ค์ •ํ•ด์คฌ๋‹ค๋ฉด, raycaster๋„ ์„ค์ •ํ•ด์ค„ ๊ฐ’๋“ค์ด ์žˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด ์ด๋Ÿฐ ์ƒํ™ฉ์ด ์žˆ๋‹ค,

 

 

๊ด‘์„ ์„ ์ˆ์„ ๋•Œ, ๋งจ ์•ž์˜ object๊ฐ€ fog ๋ฐ”๊นฅ์˜ ํšŒ์ƒ‰ object๋ผ๊ณ  ํ•ด๋ณด์ž.

๋ณ„ ์„ค์ •์ด ์—†๋‹ค๋ฉด, ์•„๋ฌด๋ฐ๋‚˜ ํด๋ฆญํ–ˆ์„ ๋•Œ ์ € ํšŒ์ƒ‰ object ๊ด€๋ จ๋œ ๊ฐ’์ด ๋„˜์–ด๊ฐˆ ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋ ‡๊ธฐ ๋•Œ๋ฌธ์— fog ๋ฐ”๊นฅ์— ์žˆ๋Š” ์š”์†Œ๋“ค์€ raycaster์— ์•ˆ์žกํžˆ๋„๋ก ํ•˜๋Š” ์ดˆ๊ธฐ๊ฐ’(near, far)์„ ์„ค์ •ํ•ด์ค˜์•ผํ•œ๋‹ค.

 

const init = useCallback(() => {
//...
    //part2: fog generator
    const near = 40;
    const far = 100;
    const color = "#ffffff";
    scene.current.fog = new THREE.Fog(color, near, far);
    
    // set boundary in raycaster same as fog.
    raycaster.current.near = near;
    raycaster.current.far = far;
}, [])

 

 

2. ๐Ÿ›‹๏ธray๋ฅผ ๋งˆ์šฐ์Šค์— ๋งž์ถฐ์„œ ์˜๋Š” ๋ฐฉ๋ฒ•.


๋ชจ๋“  object๊ฐ€ ๊ฐ™์€ x, y ๊ฐ’์„ ๊ฐ€์ง€๊ณ , z ๊ฐ’๋งŒ ๋‹ค๋ฅด๋‹ค๋ฉด ๊ธฐ์ค€ ๋ฒกํ„ฐ๋ฅผ ๊ณ ์ • ๊ฐ’์œผ๋กœ ์„ค์ •ํ•ด๋‘์–ด๋„ ๋ฌธ์ œ ์—†์ง€๋งŒ, ์นด๋ฉ”๋ผ ์˜์—ญ ์—ฌ๊ธฐ์ €๊ธฐ์— object๊ฐ€ ํผ์ ธ์žˆ๋‹ค๋ฉด, ๋งˆ์šฐ์Šค์— ๋งž์ถฐ์„œ raycater์˜ ๊ธฐ์ค€ ๋ฒกํ„ฐ๋ฅผ ๋ฐ”๊ฟ”์ค˜์•ผํ•œ๋‹ค.

 

element๋ฅผ ๋”ฑ ์ง‘์–ด๋‚ผ ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์—, ์œˆ๋„์šฐ ์ „์ฒด์— mouseEvent๋ฅผ ๊ฑธ์–ด์„œ ๋ฐ”๊ฟ”์ค˜์•ผํ•œ๋‹ค.

 

mousemove: ๋งˆ์šฐ์Šค์˜ x์ขŒํ‘œ์™€ y์ขŒํ‘œ๋กœ vector๋ฅผ ๋งŒ๋“ ๋‹ค.
click: ์œ„์—์„œ ๋งŒ๋“  ์ขŒํ‘œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ด‘์„ ์„ ์œ๋‹ค.

์ด๋Ÿฐ ๋‘ ๊ฐ€์ง€ ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ ์ธํ•ด useEffect์—์„œ ์ด๋ฒคํŠธ๋ฆฌ์Šค๋„ˆ๋ฅผ ๋งŒ๋“ค์–ด์ค˜์•ผํ•œ๋‹ค.

(์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๋Š” ์ปดํฌ๋„ŒํŠธ destroy ์‹œ ์‚ญ์ œ๋  ์ˆ˜ ์žˆ๊ฒŒ cleanup ํ•จ์ˆ˜๋กœ ๊ผญ ์ง€์›Œ์ฃผ์ž!)

 

useEffect(() => {
    init();
    animate();
    
    window.addEventListener("mousemove", setMouseMoveAxis, false);
    window.addEventListener("click", onClickObjectHandler, false);
    return () => {
      window.removeEventListener("mousemove", setMouseMoveAxis);
      window.removeEventListener("click", onClickObjectHandler);
    }
  }, [animate, init, onClickObjectHandler, setMouseMoveAxis]);

 

mousemove์˜ callback.

๋ชฉ์ ์€ url์ด ์žˆ๋Š” ์˜ค๋ธŒ์ ํŠธ๋ฅผ ๋งŒ๋‚ฌ์„ ๋•Œ ์ปค์„œ๋ฅผ pointer๋กœ ๋ณ€๊ฒฝํ•˜๊ธฐ ์œ„ํ•จ์ด๋‹ค.

 

const setMouseMoveAxis = useCallback((e: MouseEvent) => {
    mouseX.current = e.clientX;
    mouseY.current = e.clientY;
    
    // ํ™”๋ฉด์˜ ์ค‘์•™์„ (0, 0)์œผ๋กœ ์„ค์ •.
    pointer.current.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
    raycaster.current.setFromCamera(pointer.current, camera.current);
    
    // ๊ด‘์„ ๊ณผ ๊ต์ฐจํ•˜๋Š” ๋ถ€๋ถ„์„ ๋ฝ‘์•„๋ƒ„. userData์— url์ด ์žˆ๋‹ค๋ฉด cursor: pointer๋กœ ๋ณ€๊ฒฝ.
    const intersects = raycaster.current.intersectObjects(pngGroup.current.children);
    if (intersects.length > 0 && intersects.reduce((acc, val) => acc || val.object.userData?.url, false)) document.body.style.cursor = "pointer";
    else document.body.style.cursor = "auto";
  }, []);

 

click์˜ callback.

cursor: pointer์ผ ๋•Œ ๋ˆ„๋ฅด๋ฉด ์ƒˆ์ฐฝ์— ์›นํŽ˜์ด์ง€๋ฅผ ๋„์šด๋‹ค.

 

const onClickObjectHandler = useCallback((e: MouseEvent) => {
    pointer.current.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
    raycaster.current.setFromCamera(pointer.current, camera.current);
    
    const intersects = raycaster.current.intersectObjects(pngGroup.current.children);
    for (const intersect of intersects) {
      if (intersect.object.userData?.url) {
        window.open(intersect.object.userData?.url, "_blank");
        break;
      }
    }
  }, []);

 

3. Mesh์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์‚ฝ์ž…


๋ฉ”ํƒ€ ๋ฐ์ดํ„ฐ ์‚ฝ์ž…์€ mesh์˜ userData ์†์„ฑ์œผ๋กœ ์‚ฝ์ž…ํ•  ์ˆ˜ ์žˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ  ์ด๊ฑด ํ•˜๋‹ค๊ฐ€ ์•Œ๊ฒŒ ๋œ ํŒ์ธ๋ฐ, ์ด 3d ์ž‘์—…์˜ ๊ฐ€์žฅ ๊ณ ๋œ(?) ๋ถ€๋ถ„์€ ์ผ์ผํžˆ 3์ฐจ์› ๊ณต๊ฐ„์˜ ์ขŒํ‘œ๋ฅผ ๋‹ค ๊ฐ mesh๋งˆ๋‹ค ๋„ฃ์–ด์ค˜์•ผํ•œ๋‹ค๋Š” ์ ์ด๋‹ค.

๊ฐ€๋…์„ฑ์€ ๋–จ์–ด์ง€์ง€๋งŒ, ํ•œ ๋งฅ๋ฝ์•ˆ์—์„œ ๋ฌถ์–ด์„œ ๋ณด๋‚ผ ์ˆ˜ ์žˆ๋Š” ํ•จ์ˆ˜๋ฅผ ๋”ฐ๋กœ ๋งŒ๋“ค์–ด๋‘๋ฉด ์ •์‹ ๊ฑด๊ฐ•์— ์ข‹๋‹ค.

 

๋Œ€๋ถ€๋ถ„์˜ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ์ง์ ‘ object ๋ชจ์–‘์„ ๋ฐ”๊ฟ”์„œ ๋ญ”๊ฐ€๋ฅผ ํ•˜์ง€๋Š” ์•Š๋Š” ๊ฒƒ ๊ฐ™๊ณ , object์— ์‚ฌ์ง„์„ ๋ง์”Œ์šฐ๊ฑฐ๋‚˜, ๋™์˜์ƒ์„ ์–น๋Š”๋‹ค๋˜๊ฐ€, 3d model์„ ์”Œ์šด๋‹ค๋˜๊ฐ€ ํ•˜๋Š” ์ผ๋“ค์„ ํ•œ๋‹ค.

 

์ด๋•Œ ๋‹ค texture๊ฐ€ load๋˜์ง€ ์•Š์€ ์ƒํƒœ์—์„œ mesh๋ฅผ ๋งŒ๋“ค๋ฉด ๊ทธ๋ƒฅ ๊นŒ๋งŒ ๋ธ”๋Ÿญ๋งŒ ๋ณด์ด๊ฒŒ ๋œ๋‹ค.

(์‰ฝ๊ฒŒ ๋งํ•ด์„œ new THREE.textureLoader().load() ํ•จ์ˆ˜๊ฐ€ ๋น„๋™๊ธฐ๋ผ์„œ ์ด ๋ฌธ์ œ๊ฐ€ ์ผ์–ด๋‚œ๋‹ค.)

 

๋‹คํ–‰ํžˆ ์•ˆ์— texture์— ๋Œ€ํ•œ ์ฝœ๋ฐฑ ํ•จ์ˆ˜๊ฐ€ ์กด์žฌํ•ด์„œ ๊ทธ ์•ˆ์—์„œ ์ฒ˜๋ฆฌํ•ด์ฃผ๋ฉด๋œ๋‹ค. (๊ผญ promise ํ•ด์ฒด๊ฐ™๋‹ค..)

 

export type positionProps = {
  x: number;
  y: number;
  z: number;
  ratioScale?: number;
  userData?: ThreeObjectUserDataProps;
}

export interface GroupedImageRenderProps {
  srcs: string[];
  eachPosition: positionProps[];
  ratioScale?: number;
}

const renderLayerGroupedImage = useCallback((arg: GroupedImageRenderProps) => {
  const { srcs, eachPosition, ratioScale } = args;
  srcs.forEach((src, i) => {
    const imageMap = new THREE.TextureLoader().load(src, (tex) => {
        // 3d ๊ณต๊ฐ„ ์œ„์น˜ ๊ด€๋ จ ๊ฐ’๋“ค
        const { x, y, z, ratioScale: _ratioScale, userData } = eachPosition[i];
        const width = Math.floor(tex.image.width / (_ratioScale || ratioScale || 10));
        const height = Math.floor(tex.image.height / (_ratioScale || ratioScale || 10));
        
        // geometry + material => mesh ์ƒ์„ฑ
        const geometry = new THREE.BoxGeometry(width, height, 0);
        const material = new THREE.MeshBasicMaterial({ map: imageMap, transparent: true, color: 0xffffff });
        const boxMesh = new THREE.Mesh(geometry, material);
        
        // userData ์‚ฝ์ž…
        if (userData) boxMesh.userData = userData;
        boxMesh.position.set(x, y, z);
        pngGroup.current.add(boxMesh);
      });
  })
}, []);

// ๋งŒ๋“ค๋•Œ๋Š” ์ด๋ ‡๊ฒŒ ๋ฌถ์–ด์„œ ๋ณด๋‚ด๋ฉด ํŽธํ•˜๋‹ค.
renderLayerGroupedImage({ srcs: introduction, eachPosition: [
        { x: -10, y: 10, z: 0, userData: { url: "https://dev-russel.tistory.com" } },
        { x: 30, y: -7, z: 2 },
        { x: -55, y: -28, z: 4 }
      ]});

 

 

(์ถ”๊ฐ€) react portal๊ณผ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ


์ด๊ฑด webGL๋ณด๋‹ค๋Š” ์ด๋ฒคํŠธ๋ฆฌ์Šค๋„ˆ์— ๋Œ€ํ•ด์„œ ์•Œ์•„์•ผํ•  ์ ์ธ๋ฐ,

์ด raycaster๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด ์„ค์ •ํ–ˆ๋˜ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๊ฐ€ cleanup(ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ๊ฐ€ destroy)๋˜๊ธฐ ์ „๊นŒ์ง€๋Š” ๊ณ„์† ์œ ์ง€๋œ๋‹ค๋Š” ์ ์„ ์œ ์˜ํ•ด์•ผํ•œ๋‹ค.

๊ทธ๋Ÿฌ๋‹ˆ๊นŒ ๋ชจ๋‹ฌ(react-portal)๊ฐ™์€ ๊ฑธ ๋‹ค๋ฃฌ๋‹ค๊ณ  ํ–ˆ์„ ๋•Œ, ์•„์˜ˆ ๋‹ค๋ฅธ id์—์„œ ๊ทธ๋ ค์ง€์ง€๋งŒ, ์•„์ง webGL์„ ํฌํ•จํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์‚ด์•„์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋ชจ๋‹ฌ์˜ ๋งˆ์šฐ์Šค ์›€์ง์ž„์—์„œ๋„ ๊ณ„์† raycasting์ด ๋˜๊ณ  ์žˆ๊ณ , ํด๋ฆญํ–ˆ์„ ๋•Œ ๋œฌ๊ธˆ์—†๋Š” ์‚ฌ์ดํŠธ๋“ค์ด ๋‚˜์˜ฌ ์ˆ˜ ์žˆ๋‹ค๋Š” ์ ์ด๋‹ค.

 

ํ•ด๊ฒฐ๋ฐฉ๋ฒ•์€ ์˜์™ธ๋กœ ๊ฐ„๋‹จํ•œ๋ฐ, ๋ชจ๋‹ฌ์„ ์—ด๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉํ–ˆ๋˜ state๋กœ raycasting์„ ๋ง‰์•„์ฃผ๋ฉด ๋œ๋‹ค.

 

const onClickObjectHandler = useCallback((e: MouseEvent) => {
    pointer.current.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
    raycaster.current.setFromCamera(pointer.current, camera.current);
    
    // modal์ด ์—ด๋ ค์žˆ์œผ๋ฉด casting x.
    if (isModalOpened) return;
    const intersects = raycaster.current.intersectObjects(pngGroup.current.children);
    for (const intersect of intersects) {
      if (intersect.object.userData?.url) {
        window.open(intersect.object.userData?.url, "_blank");
        break;
      }
    }
  }, [isModalOpened]);