3️⃣

react-three-fiber 学習メモ

2024/05/03に公開

はじめに

主にthree.js journeyで学習した内容のメモ。スクラップだと目次ができないので記事にします。自分用。

three.js journey はとてもよいコースです。興味があるなら受講することをおすすめします。

基本

  • https://threejs-journey.com/lessons/first-r3f-application#
  • useFrame(cb: (state: RootState, delta: number) => void)で毎フレームの処理
  • const three = useThree()three.cameraとかthree.glWebGLRenderer)とかを取得できる
  • const boxRef = useRef<Mesh>(null)でメッシュに対する参照
    • boxRef.current?.rotateY(delta)で回転
App.tsx
import { useFrame, useThree } from '@react-three/fiber'
import React, { useRef } from 'react'
import { Group, Mesh } from 'three'

export default function App() {
  const three = useThree()

  const boxRef = useRef<Mesh>(null)
  const groupRef = useRef<Group>(null)

  useFrame((_, delta) => {
    boxRef.current?.rotateY(delta)
    groupRef.current?.rotateY(delta * 0.5)
  })

  return (
    <>
      <directionalLight position={[1, 2, 3]} intensity={4.5} />
      <ambientLight intensity={1.5} />

      <group ref={groupRef}>
        <mesh position-x={-2}>
          <sphereGeometry />
          <meshStandardMaterial color="blue" />
        </mesh>
        <mesh
          ref={boxRef}
          position-x={2}
          scale={1.5}
          rotation-y={Math.PI * 0.25}
        >
          <boxGeometry />
          <meshStandardMaterial color="red" />
        </mesh>
      </group>
      <mesh position-y={-1} rotation-x={-Math.PI * 0.5} scale={10}>
        <planeGeometry />
        <meshStandardMaterial color="greenyellow" />
      </mesh>
    </>
  )
}

orbitControl

  • orbitControlsコンポーネントは存在しないが、extendを使って作ることができる
  • 実際には@react-three/dreiOrbitControlsコンポーネントを使うが、一応書いておく
App.tsx
import { extend } from '@react-three/fiber'
import { OrbitControls } from 'three/examples/jsm/Addons.js'

export default function App() {
  extend({ OrbitControls })

  return (
      <orbitControls
        args={[three.camera, three.gl.domElement]}
        enableDamping
        dampingFactor={0.1}
        rotateSpeed={0.5}
      />
  )
}
  • 型がなくてリンターに怒られるので追加する
global.d.ts
declare module JSX {
  interface IntrinsicElements {
    orbitControls: any
  }
}

bufferGeometry

  • attachは、その要素を親要素にどのように適用するか
  • computeVertexNormalsは、メッシュの上面(法線)を決定する
    • これがないとdirectionalLightなどに対して陰影がつかない
  • 適宜useMemouseEffectしてく
CustomObject.tsx
import React, { useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, DoubleSide } from 'three'

type Props = { count: number; size: number }

const defaultProps: Props = {
  count: 10,
  size: 1,
}

export default function CustomObject(props: Partial<Props> | undefined) {
  const geometryRef = useRef<BufferGeometry>(null)

  const verticesCount = (props?.count || defaultProps.count) * 3 // 10 triangles
  const positions = useMemo(() => {
    const positions = new Float32Array(verticesCount * 3) // each vertex has 3 coordinates
    for (let i = 0; i < positions.length; i++) {
      positions[i] = (Math.random() - 0.5) * (props?.size || defaultProps.size)
    }
    return positions
  }, [props])

  useEffect(() => {
    geometryRef.current?.computeVertexNormals()
  })

  return (
    <mesh>
      <bufferGeometry ref={geometryRef}>
        <bufferAttribute
          attach="attributes-position"
          count={verticesCount}
          itemSize={3}
          array={positions}
        />
      </bufferGeometry>
      <meshStandardMaterial color="red" side={DoubleSide} />
    </mesh>
  )
}

サンプル

  • indexについてはこちらがわかりやすかった
  • geometry.computeVertexNormals()を呼ぶのをすっかり忘れていたのをこちらで思い出した
    • ジオメトリの各面の法線を計算する関数。法線を使ってライティングに対して描画の仕方が決定されるので、これをしないと影などなく一色で塗りつぶしたみたいになる。
SlopeGeometry.tsx
import { ExtendedColors, NodeProps, Overwrite } from '@react-three/fiber'
import { BufferGeometry } from 'three'

const size = 1
const halfSize = size / 2

const p0 = [halfSize, halfSize, -halfSize]
const p1 = [-halfSize, halfSize, -halfSize]
const p2 = [halfSize, -halfSize, -halfSize]
const p3 = [-halfSize, -halfSize, -halfSize]
const p4 = [halfSize, -halfSize, halfSize]
const p5 = [-halfSize, -halfSize, halfSize]

const faces = [
  [p0, p3, p1],
  [p0, p2, p3],
  [p0, p4, p2],
  [p0, p1, p5],
  [p0, p5, p4],
  [p1, p3, p5],
  [p2, p4, p5],
  [p2, p5, p3],
]

const vertexes = new Float32Array(faces.flat(2))

// faces の p をとって flat にならべたもの
const faceIndices = new Uint16Array([
  0, 3, 1, 0, 2, 3, 0, 4, 2, 0, 1, 5, 0, 5, 4, 1, 3, 5, 2, 4, 5, 2, 5, 3,
])

export default function SlopeGeometry(
  props: ExtendedColors<
    Overwrite<
      Partial<BufferGeometry>,
      NodeProps<BufferGeometry, typeof BufferGeometry>
    >
  >
) {
  return (
    <bufferGeometry {...props} onUpdate={(self) => self.computeVertexNormals()}>
      <bufferAttribute
        attach="attributes-position"
        array={vertexes}
        count={vertexes.length / 3}
        itemSize={3}
      />
      <bufferAttribute
        attach="attributes-index"
        array={faceIndices}
        count={faceIndices.length}
        itemSize={1}
      />
    </bufferGeometry>
  )
}

Canvasコンポーネント

  • camera: カメラの設定
    • position[x, y, z]でいい
  • gl: レンダラーの設定
    • antialias
    • toneMapping
    • outputColorSpace
  • dpr: Pixel Ratioの設定
    • デフォルトは[1, 2]min 1, max 2)で、ほとんどの場合これでいいので書かなくていい
import { Canvas } from '@react-three/fiber'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { CineonToneMapping, SRGBColorSpace } from 'three'
import App from './App.tsx'
import './style.css'

const rootEl = document.querySelector('#root')
if (!rootEl) throw new Error('Root element not found')

const root = ReactDOM.createRoot(rootEl)

root.render(
  <Canvas
    dpr={[1, 2]} // Pixel ratio
    gl={{
      antialias: true,
      toneMapping: CineonToneMapping,
      outputColorSpace: SRGBColorSpace,
    }} // WebGLRenderer props
    camera={{
      fov: 75,
      zoom: 1,
      near: 0.1,
      far: 100,
      position: [0, 1, 5],
    }} // PerspectiveCamera props
  >
    <App />
  </Canvas>
)

@react-three/drei

  • R3F用の便利ツール群

OrbitControls

  • makeDefaultとすると、他のコントロール(TransformControlsなど)の作動中はストップし、終わったら動かせるようになる
<OrbitControls enableDamping makeDefault />

TransformControls

  • オブジェクトのrefを渡して、3軸3平面上を動かすコントロールを表示する
  • refnull!しないと怒られる
  • modeでコントロールの種類を切り替える
const box = useRef<Mesh>(null!)

<TransformControls object={objectRef} /> {/* mode="transform" /*}
<TransformControls object={objectRef} mode="rotate" />
<TransformControls object={objectRef} mode="scale" />

PivotControls

  • TransformControlsに似ているが、anchorによってオブジェクトの相対的な位置に固定できる。
    • 下の例では、anchory1で球体のてっぺんにギズモが出る。球体のscale2になっているがギズモも一緒にスケールされる。
    • デフォルトでパースペクティブによってサイズが変わる
<PivotControls anchor={[0, 1, 0]} depthTest={false}>
  <mesh position-x={-2} scale={2}>
    <sphereGeometry />
    <meshStandardMaterial color="blue" />
  </mesh>
</PivotControls>

Html

  • https://github.com/pmndrs/drei?tab=readme-ov-file#html
  • HTMLを埋め込める。divタグになる。
  • centerでセンタリング
    • transform: translate(-50%, -50%)とかでもできるけど
  • occlude={ref[]}で、refのオブジェクトの後ろにきたとき非表示になる
<Html
  position={[-1, 2.5, 0]}
  center
  style={style.label}
  occlude={[sphere, box]}
>
  Hello
</Html>

Text

  • https://github.com/pmndrs/drei?tab=readme-ov-file#text
  • Three.js の 3D Text は機能的な限界があるのでdreiのSDFテキストを使うと良い。
  • fontも指定できる
    • woff, ttf, otfが指定できるが、woff(woff2)が軽くていい
    • Google Fontsからはwoffでダウンロードできないので、変換ツールを使うか、こちらのサイトからダウンロードするといい
  • 内部的にはtroika-three-textが使われている
<Text font="./bangers-v20-latin-regular.woff">Hello</Text>

// マテリアルの設定もできる
<Text position-y={4} position-z={-3} fontSize={4}>
  Yo Yo
  <meshNormalMaterial />
</Text>

Float

<Float speed={ 5 } floatIntensity={ 2 }>
  <Text>Yo Yo</Text>
</Float>

MeshReflectorMaterial

<mesh position-y={-1} rotation-x={-Math.PI * 0.5} scale={10}>
  <planeGeometry />
  <MeshReflectorMaterial
    mirror={0.5}
    resolution={256}
    blur={[1000, 1000]}
    mixBlur={1}
  />
</mesh>

Environment

background の設定

  • onCreatedで指定するパターン
const handleCreated = ({ gl }) => {
  gl.setClearColor('ivory')
}

root.render(
  <Canvas 
    onCreated={handleCreated}
  >
  </Canvas>
)
  • <color attach>を使うパターン
    • colorの親要素は実際にはSceneであり、attach="background"は親要素のbackgroundプロパティにargs[0]を設定する
root.render(
  <Canvas>
    <color args={['ivory']} attach="background" />
  </Canvas>
)

Shadows

  • 以下の4つを設定する
    • <Canvas shadows>
    • <light castShadow>
    • <mesh castShadow>
    • <mesh recieveShadow>
root.render(
  <Canvas shadows>
    <App />
  </Canvas>
)

function App() {
  return (
    <>
      <directionalLight
        castShadow 
        shadow-mapSize={[1024, 1024]} // shadow map
      />
      <mesh castShadow>{/* ... */}</mesh>
      <mesh recieveShadow>{/* ... */}</mesh>
    </>
  )
}

<BakeShadows />

import { BakeShadows } from '@react-three/drei'

return (
  <>
    <BakeShadows />
    {/* ... */}
  </>
)

shadow.camera

  • shadow-camera-topなどで指定するパターン
    • 型安全でない
      <directionalLight
        castShadow
        shadow-camera-top={2}
        shadow-camera-right={2}
        shadow-camera-bottom={-2}
        shadow-camera-left={-2}
      />
  • OrthographicCameraattachするパターン
    • 型的にはいくらかマシだがコロケーションできるのがいいか
      <directionalLight castShadow>
        <OrthographicCamera
          attach="shadow-camera"
          top={5}
          right={5}
          bottom={-5}
          left={-5}
        />
      </directionalLight>

<SoftShadows />

  • 投影面までの距離に応じてリアルな感じでぼやけるPCSS(Percentage-Closer Soft Shadows)をつける
  • パフォーマンス影響大
    • 実行中にパラメータを変えるとすべてのシェーダーが再コンパイルされるのでパフォーマンス最悪、絶対やらない
  • https://github.com/pmndrs/drei?tab=readme-ov-file#softshadows
  • サンプル
type SoftShadowsProps = {
  /** Size of the light source (the larger the softer the light), default: 25 */
  size?: number
  /** Number of samples (more samples less noise but more expensive), default: 10 */
  samples?: number
  /** Depth focus, use it to shift the focal point (where the shadow is the sharpest), default: 0 (the beginning) */
  focus?: number
}
import { SoftShadows } from '@react-three/drei'

return (
  <>
    <SoftShadows />
    {/* ... */}
  </>
)

<AccumulativeShadows /> & <RandomizedLight />

  • AccumulativeShadows
    • 平面に複数の影を集積する
    • z-fighting に気をつける
  • RandomizedLight
    • ランダムな複数のライトを生成する
    • 実質AccumulativeShadowsと組み合わせて使う他ない
<AccumulativeShadows position-y={-0.99}>
  <RandomizedLight position={[6, 8, 1]} />
</AccumulativeShadows>

<ContactShadows />

<ContactShadows
  position={[0, -0.99, 0]) // z-fighting 対策
  scale={10}
  resolution={512}
  far={5}
  color={'#1d8f75'}
  opacity={0.4}
  blur={2.8}
  frames={1} // 初回だけ描画する = bake
/>

Sky

const sunPosition = new Vector3().setFromSphericalCoords(r, phi, theta)

<Sky sunPosition={sunPosition} />

Embironment Map

<Environment
  background
  files={/* ... */}
  environmentIntensity={2}
/>

backgroundを表示しない場合

  • resolutionを下げてパフォーマンスを向上させられる
<Environment preset="sunset" resolution={32} />

メッシュを光源にする場合

  • colormeshを追加して、それを光源とすることもできる
    • 以下の場合、真っ暗な空間に大きな赤い平面が現れて、その赤色だけがシーン内を照らす
  • meshBasicMaterialcolorプロパティには配列も指定できて、0~255(0~1)の範囲を超える値をRGBに指定できる
<Environment>
  <color args={['black']} attach="background" />
  <mesh position-z={-5} scale={10}>
    <planeGeometry />
    <meshBasicMaterial color={[10, 0, 0]} />
  </mesh>
</Environment>

<Lightformer />を光源にする場合

<Environment>
  <color args={['black']} attach="background" />
  <Lightformer
    position-z={-5}
    scale={10}
    color="red"
    intensity={10}
    form="ring"
  />
</Environment>

ground

  • オブジェクトが地面に接地しているかのように表示する
    • height, radius, scaleはケースバイケースだが大体以下の値がいい感じ
<Environment preset="sunset" ground={{
    height: 7,
    radius: 28,
    scale: 100
  }}
/>

Stage

<Stage
  shadows={{ type: 'contact', opacity: 0.2, blur: 3 }}
  environment="sunset"
  preset="portrait"
  intensity={ 6 }
>
  {/* ... */}
</Stage>

Load models

モデルの読み込み

  • useLoaderまたはuseGLTFでロードして<primitive />または<Clone />で表示する

useLoaderを使う場合

SomeModel.tsx
import { useLoader } from '@react-three/fiber'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'

export default function SomeModel() {
  const model = useLoader(
    GLTFLoader,
    './models/SomeModel.gltf',
    (loader) => {
      const dracoLoader = new DRACOLoader()
      dracoLoader.setDecoderPath('./draco/')
      loader.setDRACOLoader(dracoLoader)
    }
  )
  return <primitive object={ model.scene } />
}
App.tsx
import Placeholder from './Placeholder.tsx'
import SomeModel from './SomeModel.tsx'
import { Suspense } from 'react'

export default function App() {
  return (
    <Suspense
      fallback={Placeholder}
    >
      <SomeModel />
    </Suspense>
  )
}
Placeholder.tsx
import { MeshProps } from '@react-three/fiber'

export default function Placeholder(props: MeshProps) {
  return (
    <mesh {...props}>
      <boxGeometry args={[ 1, 1, 1, 2, 2, 2 ]} />
      <meshBasicMaterial wireframe color="red" />
    </mesh>
  )
}

useGLTFを使う場合(推奨)

SomeModel.tsx
import { Clone, useGLTF } from '@react-three/drei'

export default function SomeModel() {
  const model = useGLTF('./SomeModel.gltf')
  return <Clone object={ model.scene } />
}

useGLTF.preload('./hamburger-draco.glb')

GLFT to component

Animation

AnimationAction

export default function Fox() {
  const fox = useGLTF('./Fox/glTF/Fox.gltf')
  const animations = useAnimations(fox.animations, fox.scene)
  // console.log(animations)
  // actions:
  //   Run: (...)
  //   Survery: (...)
  //   Walk: (...)
  // clips: [AnimationClip, AnimationClip, AnimationClip]
  // mixer: AnimationMixier
  // names: ['Survey', 'Walk', 'Run']
  // ref: {current: Group}

  // 走っているところからだんだん歩くように
  useEffect(() => {
    animations.actions.Run.play()
    window.setTimeout(() => {
      animations.actions.Walk.play()
      animations.actions.Walk.crossFadeFrom(animations.actions.Run, 1)
    }, 2000)
  }, [])
}

Controlからアニメーションを切り替えるサンプル

import { useAnimations, useGLTF } from '@react-three/drei'
import { useControls } from 'leva'
import { useEffect } from 'react'

export default function Fox() {
  const fox = useGLTF('./Fox/glTF/Fox.gltf')
  const animations = useAnimations(fox.animations, fox.scene)
  const { animationName } = useControls({
    animationName: { options: animations.names },
  })

  useEffect(() => {
    const action = animations.actions[animationName]
    action?.reset().fadeIn(0.5).play()

    return () => {
      action?.fadeOut(0.5)
    }
  }, [animationName, animations.actions])

  return (
    <primitive
      object={fox.scene}
      scale={0.02}
    />
  )
}

3D Text

<Text3D />

import { Center, Text3D } from '@react-three/drei'

export default function Text() {
  return (
    <Center>
      <Text3D
        font="/path/to/font.json"
        size={0.75}
        height={0.2}
        curveSegments={12}
        bevelEnabled
        bevelThickness={0.02}
        bevelSize={0.02}
        bevelOffset={0}
        bevelSegments={5}
      >
        HELLO R3F
        <meshNormalMaterial />
      </Text3D>
    </Center>
  )
}

Matcap

  • MatCap (Material Capture, also known as LitSphere)
    • Normalに応じて変化する光源と反射を内包したテクスチャ
    • カメラが動かない場合はとても使えるが、カメラが動く場合カメラではなくオブジェクトが回転しているように見える

useMatcapTexture

import { useMatcapTexture } from '@react-three/drei'

const [matcapTexture] = useMatcapTexture('6D3B1C_895638_502A0D_844C31', 256)

// ...

<meshMatcapMaterial matcap={matcapTexture} />

パフォーマンス改善

useStateでジオメトリ・マテリアルを再利用する場合

  • 記述量も多いし型も噛み合ってないので次のやり方のほうがよさそう
import { useMatcapTexture, useNormalTexture } from '@react-three/drei'
import { useState } from 'react'
import { Euler, MeshMatcapMaterial, TorusGeometry, Vector3 } from 'three'

export default function Donuts() {
  const [matcapTexture] = useMatcapTexture('6D3B1C_895638_502A0D_844C31', 256)
  const [normalTexture] = useNormalTexture(43)

  const [geometry, setGeometry] = useState<TorusGeometry | null>()
  const [material, setMaterial] = useState<MeshMatcapMaterial | null>()

  return (
    <>
      <torusGeometry ref={setGeometry} />
      <meshMatcapMaterial
        ref={setMaterial}
        matcap={matcapTexture}
        normalMap={normalTexture}
      />

      {Array.from({ length: 100 }).map((_, i) => (
        <mesh
          {...getRandomDonutProps()}
          key={i}
          material={material!}
          geometry={geometry!}
        />
      ))}
    </>
  )
}

Three.jsを使ってコンポーネントの外でジオメトリ・マテリアルを作成する場合

  • 単発のuseEffectで初期化する
const torusGeometry = new TorusGeometry(1, 0.6, 16, 32)
const material = new MeshMatcapMaterial()

export default function Donuts() {
  const [matcapTexture] = useMatcapTexture('6D3B1C_895638_502A0D_844C31', 256)
  useEffect(() => {
    matcapTexture.colorSpace = SRGBColorSpace
    material.matcap = matcapTexture
    material.needsUpdate = true
  }, [])
}

複数のref

すべてのドーナツを回転させる

export default function Donuts() {
  const donuts = useRef<(Mesh | null)[]>([])

  useFrame((_, delta) => {
    donuts.current.forEach((donut) => {
      donut?.rotateY(delta * 0.1)
    })
  })

  return (
    <>
      {Array.from({ length: 100 }).map((_, i) => (
        <mesh
          ref={(ref) => (donuts.current[i] = ref)}
          {...getRandomDonutProps()}
          key={i}
        />
      ))}
    </>
  )
}

Mouse Events

イベント一覧

<mesh
  onClick={(e) => console.log('click')}
  onContextMenu={(e) => console.log('context menu')}
  onDoubleClick={(e) => console.log('double click')}
  onWheel={(e) => console.log('wheel spins')}
  onPointerUp={(e) => console.log('up')}
  onPointerDown={(e) => console.log('down')}
  onPointerOver={(e) => console.log('over')}
  onPointerOut={(e) => console.log('out')}
  onPointerEnter={(e) => console.log('enter')}
  onPointerLeave={(e) => console.log('leave')}
  onPointerMove={(e) => console.log('move')}
  onPointerMissed={() => console.log('missed')}
  onUpdate={(self) => console.log('props have been updated')}
/>

stopPropagation

  • マウスイベントは手前から奥に伝播する。止めたいところでe.stopPropagation()する。

カーソルをポインターに

<mesh
  onClick={ eventHandler }
  onPointerEnter={ () => { document.body.style.cursor = 'pointer' } }
  onPointerLeave={ () => { document.body.style.cursor = 'default' } }
/>
const [hovered, set] = useState()
useCursor(hovered, /*'pointer', 'auto', document.body*/)
return (
  <mesh onPointerOver={() => set(true)} onPointerOut={() => set(false)}>

パフォーマンス改善

  • 毎フレームテストされるイベントはなるべく避ける
    • onPointerOver, onPointerEnter, onPointerOut, onPointerLeave, onPointerMove
  • meshBoundsヘルパーを使って Bounding Sphere を当たり判定に使う
<mesh raycast={meshBounds} onClick={handleClick} />
import { Bvh } from '@react-three/drei'

root.render(
  <Canvas camera={camera}>
    <Bvh>
      <App />
    </Bvh>
  </Canvas>
)

Discussion