🤖

React+TypeScript+R3Fのtutorial応用編2(Octreeで衝突判定の絞り込み)

2024/01/02に公開

Abstract

今回の参考はここ(FPS Octree)。このソースをTypeScriptで実装しなおす。
ここで使う3Dモデルも上記のモデルを、ありがたく使わせてもらいました。

ポイント

  • Octree
  • PointerLockControl ... マウスを非表示にして、マウス動きに追従する
    ↑実はコレ、コメント化してて、OrbitControlsを有効化している。

結論

今回の成果物はココ↓
https://github.com/aaaa1597/react-r3f-advanced002

前提

手順

1.プロジェクト生成 -> VSCodeで開く

めんどいから、このスケルトンコードから始める。react-r3f-base-onebox
で、下記コマンドでフォルダ名とか整備する。

フォルダリネームとか
$ D:
$ cd .\Products\React.js\            # ご自身の適当なフォルダで。
$ rd /q /s D:\Products\React.js\react-r3f-advanced002
$ git clone https://github.com/aaaa1597/react-r3f-base-onebox.git
$ rd /q /s react-r3f-base-onebox/.git
$ ren react-r3f-base-onebox react-r3f-advanced002
$ cd react-r3f-advanced002

準備

コマンドプロンプト
$ npm install --save three
$ npm install --save @types/three
$ npm install --save @react-three/fiber
$ npm install --save @react-three/drei
$ npm install --save-dev leva
$ npm install --save-dev gltfjsx

準備2

  • [ここ](https://github.com/aaaa1597/react-r3f- advanced002/blob/main/public/assets/kloofendal_48d_partly_cloudy_puresky_1k.hdr)からDLしたkloofendal_48d_partly_cloudy_puresky_1k.hdr をプロジェクトのreact-r3f-advanced002/public/assets/"配下にコピー。
  • [ここ](https://github.com/aaaa1597/react-r3f- advanced002/blob/main/public/assets/Apartment.glb)からDLしたApartment.glb をプロジェクトの"react-r3f-advanced002/public/assets/"配下にコピー。

npx gltfjsxコマンドを実行 -> TSXファイル生成

'C:\Users\xxx\AppData\Roaming'配下にnpmフォルダを作って、npx gltfjsxコマンド実行。

コマンドプロンプト
$ npx gltfjsx public/assets/house-water-transformed.glb --types --shadows 

参考
で、プロジェクトフォルダに出力されたApartment2.tsxを自分のプロジェクトに持ってくる。

.eslintrc.jsを修正

エラーになるので、ignoreに追加

.eslintrc.js
+        "@typescript-eslint/no-namespace": "off",
+        "react/jsx-uses-react": "off",
+        "react/react-in-jsx-scope": "off",
-        "react/no-unknown-property": ['error', { ignore: ['css', "args", 'wireframe', 'rotation-x', 'rotation'] }],
+        "react/no-unknown-property": ['error', { ignore: [
+            'args', 'intensity', 'castShadow', 'shadow-bias', 'shadow-radius', 'shadow-blur', 'shadow-mapSize', 'position', 'rotation',
+            'shadow-camera-left', 'shadow-camera-right', 'shadow-camera-top', 'shadow-camera-bottom', 'dispose', 'receiveShadow', 'geometry', 'material'] }],

App.tsx

まず全体。

App.tsx
import React, {useRef} from 'react';
import './App.css';
import { Canvas, useFrame, MeshProps  } from '@react-three/fiber'
import * as THREE from 'three'
-import { OrbitControls, Environment } from '@react-three/drei'
+import { Stats, Environment, OrbitControls, PointerLockControls } from '@react-three/drei'
+import Game from './Game';

const Box = (props: MeshProps) => {
  const ref = useRef<THREE.Mesh>(null!)

  useFrame((_, delta) => {
    if( !ref.current) return
    ref.current.rotation.x += 1 * delta
    ref.current.rotation.y += 0.5 * delta
  })

  return (
    <mesh {...props} ref={ref}>
      <boxGeometry />
      <meshNormalMaterial />
    </mesh>
  )
}

const App = () => {
  return (
-    <div style={{ width: "75vw", height: "75vh" }}>
+    <div style={{ width: "100vw", height: "75vh" }}>
-     <Canvas camera={{ position: [3, 1, 2] }}>      
+     <Canvas shadows>
+       <directionalLight
+           intensity={1}
+           castShadow={true}
+           shadow-bias={-0.00015}
+           shadow-radius={4}
+           shadow-blur={10}
+           shadow-mapSize={[2048, 2048]}
+           position={[85.0, 80.0, 70.0]}
+           shadow-camera-left={-30}
+           shadow-camera-right={30}
+           shadow-camera-top={30}
+           shadow-camera-bottom={-30}
+         />
        <Box position={[1, 1, 1]} name="A" />
-        <Environment preset="forest" background />	
+       <Environment files="/assets/kloofendal_48d_partly_cloudy_puresky_1k.hdr" background />
+       <Game />
        <OrbitControls />
+       {/* <PointerLockControls /> */}
        <axesHelper args={[5]} />
        <gridHelper />
+       <Stats />
      </Canvas>
    </div>
  );
}

export default App;

全体構成を定義している。"<Game />"以外は特に普通。

Game.tsx

Game.tsx
import { useEffect, useMemo, useRef } from 'react'
import { OctreeHelper } from 'three/examples/jsm/helpers/OctreeHelper'
import { useThree } from '@react-three/fiber'
import { useGLTF } from '@react-three/drei'
import { Octree } from 'three/examples/jsm/math/Octree'
import { Capsule } from 'three/examples/jsm/math/Capsule'
import { useControls } from 'leva'
import * as THREE from 'three'
import SphereCollider from './SphereCollider'
import { GLTF } from 'three-stdlib'
import Player from './Player'

namespace Constants {
    export const ballCount = 100
    export const radius = 0.2
    export const balls = [...Array(ballCount)].map(() => ({ position: [Math.random() * 50 - 25, 20, Math.random() * 50 - 25] }))
    export const v1 = new THREE.Vector3()
    export const v2 = new THREE.Vector3()
    export const v3 = new THREE.Vector3()
}

type GLTFResult = GLTF & {
    nodes: { Suzanne007: THREE.Mesh }
    materials: { Suzanne007: THREE.MeshStandardMaterial }
    scene: THREE.Object3D<THREE.Object3DEventMap>
}

type SphereCapsule = {
    sphere?: THREE.Sphere,
    velocity:THREE.Vector3,
    capsule?:Capsule,
}

const useOctreeHelper = (octree: Octree) => {
    //console.log('in useOctreeHelper')
    const { scene } = useThree()
    useEffect(() => {
        console.log('new OctreeHelper')
        const helper = new OctreeHelper(octree, 'hotpink')
        helper.name = 'octreeHelper'
        scene.add(helper)
        return () => {
            console.log('removing OctreeHelper')
            scene.remove(helper)
        }
    }, [octree, scene])

    useControls('Octree Helper', {
        visible: {
            value: false,
            onChange: (v) => {
                scene.getObjectByName('octreeHelper')!.visible = v
                //if (document.getElementById('Octree Helper.visible')) document.getElementById('Octree Helper.visible').blur()
            }
        }
    })
}
  
const useOctree = (scene: THREE.Object3D<THREE.Object3DEventMap>) => {
    console.log('in useOctree')
    const octree = useMemo(() => {
        console.log('new Octree')
        return new Octree().fromGraphNode(scene)
    }, [scene])

    return octree
}
  
const Ball = (props: { radius: number }) => {
    return (
      <mesh castShadow>
        <sphereGeometry args={[props.radius, 16, 16]} />
        <meshStandardMaterial />
        {/* <meshNormalMaterial wireframe /> */}
      </mesh>
    )
  }
  
const Physics = () => {
    // const { nodes, scene } = useGLTF('assets/Apartment2.glb') as GLTFResult
    const { nodes, scene } = useGLTF('assets/scene-transformed.glb') as GLTFResult
    const octree = useOctree(scene)
    useOctreeHelper(octree)

    const colliders = useRef<SphereCapsule[]>([])

    const checkSphereCollisions = (sphere: THREE.Sphere, velocity: THREE.Vector3) => {
        for (let i = 0, length = colliders.current.length; i < length; i++) {
            const c = colliders.current[i]
            if (c.sphere) {
                const d2 = sphere.center.distanceToSquared(c.sphere.center)
                const r = sphere.radius + c.sphere.radius
                const r2 = r * r
        
                if (d2 < r2) {
                    const normal  = Constants.v1.subVectors(sphere.center, c.sphere.center).normalize()
                    const impact1 = Constants.v2.copy(normal).multiplyScalar(normal.dot(velocity))
                    const impact2 = Constants.v3.copy(normal).multiplyScalar(normal.dot(c.velocity))
                      velocity.add(impact2).sub(impact1)
                    c.velocity.add(impact1).sub(impact2)
                    const d = (r - Math.sqrt(d2)) / 2
                      sphere.center.addScaledVector(normal,  d)
                    c.sphere.center.addScaledVector(normal, -d)
                  }
            }
            else if (c.capsule) {
                const center = Constants.v1.addVectors(c.capsule.start, c.capsule.end).multiplyScalar(0.5)
                const r = sphere.radius + c.capsule.radius
                const r2 = r * r
                for (const point of [c.capsule.start, c.capsule.end, center]) {
                    const d2 = point.distanceToSquared(sphere.center)
                    if (d2 < r2) {
                        const normal  = Constants.v1.subVectors(point, sphere.center).normalize()
                        const impact1 = Constants.v2.copy(normal).multiplyScalar(normal.dot(c.velocity))
                        const impact2 = Constants.v3.copy(normal).multiplyScalar(normal.dot(velocity))
                        c.velocity.add(impact2).sub(impact1)
                          velocity.add(impact1).sub(impact2)
                        const d = (r - Math.sqrt(d2)) / 2
                        sphere.center.addScaledVector(normal, -d)
                    }
                }
            }
        }
    }

    return (
        <>
            <group dispose={null}>
                <mesh castShadow receiveShadow geometry={nodes.Suzanne007.geometry} material={nodes.Suzanne007.material} position={[1.74, 1.04, 24.97]} />
            </group>
            {Constants.balls.map(({ position }, i) => (
                <SphereCollider key={i} id={i} radius={Constants.radius} octree={octree} position={position} colliders={colliders.current} checkSphereCollisions={checkSphereCollisions}>
                    <Ball radius={Constants.radius} />
                </SphereCollider>
            ))}
            <Player ballCount={Constants.ballCount} octree={octree} colliders={colliders.current} />
        </>
    )
}
export default Physics;
  • Game.tsxをimportさせるのに、エントリポイントは、Physics()という。こんな使い方もできるんやね。なんか気持ち悪い。
  • scene-transformed.glbのモデルをuseGLTF()で読込むと、GLTFResult型が返ってくるらしい。
  • GLTFResult型は、以下の型を取る。いろんなモデルをgit gltfjsxに掛けてみると分かった。
type GLTFResult = GLTF & {
    nodes: { なんちゃら: THREE.Mesh }
    materials: { なんちゃら: THREE.MeshStandardMaterial }
    scene: THREE.Object3D<THREE.Object3DEventMap>
}
  • GLTFResult型のなんちゃらの部分は、モデルをBlenderで開くと、Suzanne007しかいなかったのでそれを設定。いっぱいいたら、いっぱい設定する。

あと、Player.tsxとか、SphereCollider.tsxとかいるけど、公開しているのでgithubの方を見てください。

で、実行。

出来た!!

  • W/A/S/Dボタン押下で移動。
  • マウスクリックで発射。

ハマりポイント

javascript → typescriptに変更するのに、結構ハマったけど...。
いろいろありすぎて、覚えてない~。
ただ、TypeScriptの型に関してはいつも苦しむ。

宿題

  1. 起動すぐのカメラの画角を調整したい。向きとか、サイズとか。
  2. 真ん中にあるカラフルな立方体には、衝突判定が効いてない。効かせたい。
  3. 独自の3Dモデルに置き換えて、挑戦したい。

React+TypeScript+R3Fのtutorial応用編1(annotations, GLTFSX, SVG)


React+TypeScript+R3Fのtutorial応用編3(glTFで3Dアニメーション(単一モデル))

Discussion