みかづきブログ・カスタム

基本的にはちょちょいのほいです。

マウスで3Dオブジェクトを掴んで放り投げる(Cannon.js + Three.js + React.js) 🐭

f:id:kimizuka:20220304223001g:plain

Cannon.js + Three.jsを使って、マウスでオブジェクトを掴めるようにしてみました。
物理演算をしているので放り投げることもできます。

ざっくりとした流れとしては、

❶ Cannon.jsで計算用のworldをつくる
❷ 毎フレーム、Cannon.jsでオブジェクトの位置を計算する
❸ 毎フレーム、Three.jsのオブジェクトの位置と姿勢をCannon.jsの位置と姿勢に合わせる

に加え、

❹ マウスダウン時にマウスと交差しているオブジェクトを探し、交差点を割り出す
❺ 交差点とオブジェクトの中心座標分でベクトルをつくる
❻ ❺のベクトルを逆向きにしてpivotを設定
❼ マウスのオブジェクトの交差点(❻のpivotを設定)とオブジェクトの中心点を結ぶように、ワールドに力を掛ける
❽ マウスムーブ時に❼を更新する
❾ マウスアップ時にワールドに掛けた力を解除する

という感じです。


補足

上記説明だと、重要な点がpivotです
pivotを設定しないとオブジェクトの中心とマウス位置のずれ分余分な力が発生してしまいます。

こちらpivotを設定していないバージョンですが、うまく中心近くを選択できれば違和感はないですが、中心から遠いところを選択すると回転してしまうことがわかります。

kimizuka.org

f:id:kimizuka:20220305150651g:plain


ソースコード

useCannon.tsx

import * as CANNON from 'cannon-es';
import { useEffect, useRef, useState } from 'react';

type TypeCannonBody = {
  body: CANNON.Body;
  mesh: any;
  isHit?: boolean;
};

export default function useCannon() {
  const cannonObjectListRef = useRef<TypeCannonBody[]>([]);
  const [ world ] = useState(new CANNON.World());
  const [ cannonObjectList, setCannonObjectList ] = useState<TypeCannonBody[]>([]);
  const [ hitCannonObjectList, setHitCannonObjectList ] = useState<string[]>([]);
  const currsor = new CANNON.Body({
    mass: 0,
    shape: new CANNON.Sphere(1)
  });

  useEffect(() => {
    world.gravity.set(0, -9.81, 0);
  }, [world]);

  function addCannonObject({ body, mesh, isHit = false }: TypeCannonBody) {
    cannonObjectListRef.current.push({
      body,
      mesh
    });

    setCannonObjectList(cannonObjectListRef.current);
    world.addBody(body);

    if (isHit) {
      setHitCannonObjectList([...hitCannonObjectList, mesh.uuid]);
    }
  }

  function findCannonBodyFromId(id: string) {
    let body: CANNON.Body | null = null;

    cannonObjectList.forEach((obj) => {
      if (!body && obj.mesh.uuid === id) {
        body = obj.body;
      }
    });

    return body;
  }

  function upDateCannonObject() {
    cannonObjectList.forEach(({ mesh, body }) => {
      mesh.position.copy(body.position as any);
      mesh.quaternion.copy(body.quaternion as any);
    });

    world.fixedStep();
  }

  return {
    CANNON,
    world,
    currsor,
    cannonObjectList,
    hitCannonObjectList,
    addCannonObject,
    findCannonBodyFromId,
    upDateCannonObject
  };
}

useThree.tsx

import { useEffect, useState } from 'react';
import * as THREE from '~/scripts/build/three.module';

export default function useThree(canvas: HTMLCanvasElement | null) {
  const [ renderer, setRenderer ] = useState<THREE.WebGLRenderer>();
  const [ camera, setCamera ] = useState<THREE.PerspectiveCamera>();
  const [ scene ] = useState<THREE.Scene>(new THREE.Scene);

  useEffect(() => {
    if (!canvas) {
      return;
    }

    setRenderer(new THREE.WebGLRenderer({
      canvas,
      antialias: true,
      alpha: true
    }));
  }, [canvas]);

  useEffect(() => {
    if (!renderer) {
      return;
    }

    setCamera(new THREE.PerspectiveCamera());
  }, [renderer]);

  return { THREE, renderer, camera, scene };
}

index.jsx

import { useEffect, useRef, useState } from 'react';
import { client } from '~/scripts/client';
import useCannon from '~/hooks/useCannon';
import useResize from '~/hooks/useResize';
import useThree from '~/hooks/useThree';

export default function IndexPage() {
  const canvasRef = useRef(null);
  const targerCannonObjectRef = useRef(null);
  const hitAreaPlainMeshRef = useRef(null);
  const pointToPointConstraintRef = useRef(null);
  const debugCurrsorRef = useRef(null);
  const {
    CANNON,
    world,
    currsor,
    cannonObjectList,
    hitCannonObjectList,
    addCannonObject,
    findCannonBodyFromId,
    upDateCannonObject
  } = useCannon();
  const { windowWidth, windowHeight } = useResize();
  const { THREE, renderer, camera, scene } = useThree(canvasRef.current);
  const [ isDebug, setIsDebug ] = useState(false);

  useEffect(() => {
    if (!THREE || !renderer || !camera) {
      return;
    }

    if (!cannonObjectList.length) {
      debugCurrsorRef.current = new THREE.Mesh(
        new THREE.SphereGeometry(.5, 16, 16),
        new THREE.MeshNormalMaterial()
      );

      debugCurrsorRef.current.material.trasparent = true;
      debugCurrsorRef.current.material.alphaToCoverage = true;
      debugCurrsorRef.current.material.opacity = .8;
      debugCurrsorRef.current.visible = false;

      scene.add(debugCurrsorRef.current);

      const floorBody = new CANNON.Body({
        mass: 0,
        shape: new CANNON.Plane()
      });

      floorBody.position.set(0, 0, 0);
      floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);

      const floor = new THREE.Mesh(
        new THREE.PlaneGeometry(8, 8, 1, 1),
        new THREE.MeshStandardMaterial({ color: 0xEEEEEE })
      );

      floor.receiveShadow = true;

      addCannonObject({
        body: floorBody,
        mesh: floor
      });
      scene.add(floor);

      const cubeBody = new CANNON.Body({
        mass: 1,
        shape: new CANNON.Box(
          new CANNON.Vec3(.5, .5, .5)
        )
      });

      cubeBody.position.set(0, 4, 0);
      cubeBody.angularVelocity.set(1, 1, 1);

      const cube = new THREE.Mesh(
        new THREE.BoxGeometry(1, 1, 1),
        new THREE.MeshStandardMaterial({ color: 0xFF0000 })
      );

      cube.castShadow = true;

      addCannonObject({
        body: cubeBody,
        mesh: cube,
        isHit: true
      });
      scene.add(cube);

      return;
    }

    renderer.setClearColor(0xFFFFFF, 1);
    renderer.shadowMapEnabled = true;
    camera.position.set(8, 8, 8);
    camera.lookAt(new THREE.Vector3(0, 0, 0));

    (() => {
      const light = new THREE.AmbientLight(0xFFFFFF, .4);

      scene.add(light);
    })();

    const light = new THREE.DirectionalLight();

    light.position.set(-8, 8, 8);
    light.castShadow = true;
    light.shadowMapWidth = 1024;
    light.shadowMapHeight = 1024;

    scene.add(light);

    renderer.shadowMapEnabled = true;
    renderer.setAnimationLoop(() => {
      upDateCannonObject();
      renderer.render(scene, camera);
    });
  }, [THREE, renderer, camera, cannonObjectList]);

  useEffect(() => {
    if (!renderer || !camera) {
      return;
    }

    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(windowWidth, windowHeight);

    camera.aspect = windowWidth / windowHeight;
    camera.updateProjectionMatrix();
  }, [renderer, camera, windowWidth, windowHeight]);

  function findIntersectObjects(x, y, meshes) {
    const raycaster = new THREE.Raycaster();
    const vector = new THREE.Vector3(
      (x / window.innerWidth) * 2 - 1,
      (y / window.innerHeight) * -2 + 1,
      .5
    );

    raycaster.setFromCamera(vector, camera);

    return raycaster.intersectObjects(meshes);
  }

  function handlePointerDown(evt) {
    const intersects = findIntersectObjects(evt.clientX, evt.clientY, scene.children);

    intersects.forEach((intersect) => {
      if (hitCannonObjectList.includes(intersect.object.uuid)) {
        if (!targerCannonObjectRef.current) {
          const { point } = intersect;

          targerCannonObjectRef.current = intersect;
          setHitAreaPlain(point);
          addPointToPointConstraint(point.x, point.y, point.z, findCannonBodyFromId(intersect.object.uuid));
        }
      }
    });
  }

  function handlePointerMove(evt) {
    if (
      targerCannonObjectRef.current &&
      hitAreaPlainMeshRef.current &&
      pointToPointConstraintRef.current
    ) {

      const intersect = findIntersectObjects(evt.clientX, evt.clientY, [hitAreaPlainMeshRef.current])[0];

      if (intersect) {
        const { point } = intersect;

        setHitAreaPlain(point);
        pointToPointConstraintRef.current.update();
      } else {
        handlePointerUp();
      }
    }
  }

  function handlePointerUp() {
    targerCannonObjectRef.current = null;
    world.removeConstraint(pointToPointConstraintRef.current);
    pointToPointConstraintRef.current = null;
    debugCurrsorRef.current.visible = false;
  }

  function setHitAreaPlain(point) {
    if (!hitAreaPlainMeshRef.current) {
      hitAreaPlainMeshRef.current = new THREE.Mesh(
        new THREE.PlaneGeometry(1, 1),
        new THREE.MeshNormalMaterial()
      );
      hitAreaPlainMeshRef.current.visible = false;

      scene.add(hitAreaPlainMeshRef.current);
    }

    hitAreaPlainMeshRef.current.position.copy(point);
    hitAreaPlainMeshRef.current.quaternion.copy(camera.quaternion);
    currsor.position.set(point.x, point.y, point.z);
    debugCurrsorRef.current.visible = isDebug;
    debugCurrsorRef.current.position.set(point.x, point.y, point.z);
  }

  function addPointToPointConstraint(x, y, z, body) {
    const v1 = new CANNON.Vec3(x, y, z).vsub(body.position);
    const pivot = body.quaternion.inverse().vmult(v1); 

    currsor.position.set(x, y, z);

    pointToPointConstraintRef.current = new CANNON.PointToPointConstraint(
      currsor,
      new CANNON.Vec3(0, 0, 0),
      body,
      pivot
    );

    world.addConstraint(pointToPointConstraintRef.current);
  }

  function handleInputDebug() {
    setIsDebug(!isDebug);
  }

  return (
    <>
      <MockHead content={ contents[0] } />
      <canvas
        onPointerDown={ handlePointerDown }
        onPointerMove={ handlePointerMove }
        onPointerUp={ handlePointerUp }
        ref={ canvasRef }
      />
      <label
        style={{
          display: 'flex',
          alignItems: 'center',
          position: 'fixed',
          top: '16px',
          right: '16px',
          fontSize: '10px'
        }}
      >
        <input
          type="checkbox"
          onChange={ handleInputDebug }
          checked={ isDebug }
        />
        <span>debug</span>
      </label>
    </>
  );
}

これを応用して、VR版も作ってみたのですが、まだ探り探りなので、もうちょっと検証しようと思います。

f:id:kimizuka:20220304233207g:plain

kimizuka.org