React

【React/Three.js】Reactでリッチなサイト作りたい。

React
この記事は約14分で読めます。

はじめに

Reactを触っていると誰しもリッチなサイトを作りたいと思ったことはあると思います。
あるはずです。

ということでThree.jsがReactで簡単に使えるreact-three-fiberを使用してみました。
とりわけ私はThree.jsが元々使えたわけでもなく、「WebGLって何でしょうか」というレベル感です。
そんなレベルの人がThree.jsとreact-three-fiberのドキュメントを読みながら手を半日動かすとこんなものができるといった感じです。

まずできたものを5秒だけお見せします。
中心にカッコ良いモヤモヤがありますね。こちらのnoiseはMITライセンスのものをお借りしております。
周りのBox奥に向かって動き続けますがhoverすると手前に引っ張って来れる感じですね。

こちらにホスティングしたものと、ソースコードのGiuhubリンクを置いておきますので必要に応じてご覧ください。

https://threejs-example-seven.vercel.app/
GitHub - Hizuraky/threejs-example
Contribute to Hizuraky/threejs-example development by creating an account on GitHub.

サンプルコード

今回はNext.jsで作成してます。とはいえReactでもコードはほとんど変わりません。

index.tsx
import type { NextPage } from "next"
import { TCanvas } from "../components/TCanvas"

const Home: NextPage = () => {
  return (
    <div>
      <TCanvas />
    </div>
  )
}

export default Home

ただ TCanvasという下記で作成しているコンポーネントを表示しているだけです。

global.css
.canvas{
  width: 100vw;
  height:100vh;
  background:radial-gradient(#767995, #050725);
}

下記のコンポーネントを画面一杯に広がるようにサイズ指定をして背景にグラデーションをつけました。

TCanvas.tsx
import * as THREE from "three"
import React, { useRef, useState, useMemo } from "react"
import { Canvas, useFrame } from "@react-three/fiber"
import { vertexShader, fragmentShader } from "./shader"

export const TCanvas = () => {
  let frame = 0

  const Box = ({ position }: { position: number[] }) => {
    const ref = useRef<THREE.Mesh>(null!)
    const [hovered, hover] = useState(false)

    useFrame(() => {
      ref.current.rotation.x += 0.1
      ref.current.rotation.y += 0.1
      ref.current.position.z -= 0.001
      if (hovered) {
        ref.current.position.x += position[0] < 0 ? -0.0008 : 0.0008
        ref.current.position.y += position[1] < 0 ? -0.0008 : 0.0008
        ref.current.position.z += 0.0035
      }
    })

    return (
      <mesh
        position={
          new THREE.Vector3(
            ref.current?.position.x ?? position[0],
            ref.current?.position.y ?? position[1],
            ref.current?.position.z ?? position[2]
          )
        }
        ref={ref}
        onPointerOver={() => hover(true)}
        onPointerOut={() => hover(false)}
      >
        <boxGeometry args={[0.01, 0.01, 0.01]} />
        <meshStandardMaterial
          color={"rgb(" + ~~(30 * Math.random()) + ", " + ~~(30 * Math.random()) + ", " + ~~(200 * Math.random()) + ")"}
        />
      </mesh>
    )
  }

  const Plane = () => {
    const planePositions = useMemo(() => {
      const planeGeometry = new THREE.PlaneGeometry(6, 6, 128, 128)
      const positions = planeGeometry.attributes.position.array

      return positions
    }, [])

    const shaderArgs = useMemo(
      () => ({
        uniforms: {
          uTime: { value: 0 }
        },
        vertexShader,
        fragmentShader
      }),
      []
    )

    const vec = new THREE.Vector3()

    useFrame((state) => {
      shaderArgs.uniforms.uTime.value++
      frame < 3 && (frame += 0.0005)
      state.camera.position.lerp(vec.set(0, 0, frame), 0.1)
      state.camera.updateProjectionMatrix()
    })

    const PlaneGeometry = () => (
      <>
        <bufferGeometry>
          <bufferAttribute attach="attributes-position" array={planePositions} itemSize={3} count={planePositions.length / 3} />
        </bufferGeometry>
        <shaderMaterial args={[shaderArgs]} depthTest={false} depthWrite={false} />
      </>
    )

    return (
      <>
        <points rotation={[-Math.PI / 3, 1, 3]}>
          <PlaneGeometry />
        </points>
        <points rotation={[-Math.PI / -3, 1, -3]}>
          <PlaneGeometry />
        </points>
      </>
    )
  }

  // 中心に近いところから10->20->40->50->60個のBoxを生成する
  return (
    <div className="canvas">
      <Canvas>
        <pointLight position={[1, 1, 1]} />
        {[...Array(10)].map((_, i) => (
          <Box key={`boxes1-${i}`} position={[Math.random() * 0.8 - 0.4, Math.random() * 0.8 - 0.4, Math.random() - 0.5]} />
        ))}
        {[...Array(20)].map((_, i) => (
          <Box key={`boxes2-${i}`} position={[Math.random() * 1.6 - 0.8, Math.random() * 1.6 - 0.8, Math.random() - 0.1]} />
        ))}
        {[...Array(40)].map((_, i) => (
          <Box key={`boxes3-${i}`} position={[Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random() + 1]} />
        ))}
        {[...Array(50)].map((_, i) => (
          <Box key={`boxes4-${i}`} position={[Math.random() * 4 - 2, Math.random() * 4 - 2, Math.random() + 2]} />
        ))}
        {[...Array(60)].map((_, i) => (
          <Box key={`boxes5-${i}`} position={[Math.random() * 6 - 3, Math.random() * 6 - 3, Math.random() + 3]} />
        ))}
        <Plane />
      </Canvas>
    </div>
  )
}

少し長くなりました。

下記の部分が1つ1つのボックスの描画です。

const Box = ({ position }: { position: number[] }) => {
    const ref = useRef<THREE.Mesh>(null!)
    const [hovered, hover] = useState(false)

    useFrame(() => {
      // ボックスがくるくる回るアニメーション
      ref.current.rotation.x += 0.1
      ref.current.rotation.y += 0.1

      // ボックスが常に奥に動き続ける
      ref.current.position.z -= 0.001

           // ボバー時に手前外側に動くアニメーション
      if (hovered) {
        ref.current.position.x += position[0] < 0 ? -0.0008 : 0.0008
        ref.current.position.y += position[1] < 0 ? -0.0008 : 0.0008
        ref.current.position.z += 0.0035
      }
    })

    return (
      <mesh
        position={
          new THREE.Vector3(
            // ボックスの位置。初回表示時は乱数で指定し、それ以降はrefの値を適用(ホバーで動くため)
            ref.current?.position.x ?? position[0],
            ref.current?.position.y ?? position[1],
            ref.current?.position.z ?? position[2]
          )
        }
        ref={ref}
        onPointerOver={() => hover(true)}
        onPointerOut={() => hover(false)}
      >
        {/* ボックス描画。argsはサイズ */}
        <boxGeometry args={[0.01, 0.01, 0.01]} />

        {/* ボックスの色を青系になるよう乱数で指定 */}
        <meshStandardMaterial
          color={"rgb(" + ~~(30 * Math.random()) + ", " + ~~(30 * Math.random()) + ", " + ~~(200 * Math.random()) + ")"}
        />
      </mesh>
    )
  }

下記の部分がモヤモヤの描画です。

const Plane = () => {
    // もやもやの位置
    const planePositions = useMemo(() => {
      const planeGeometry = new THREE.PlaneGeometry(6, 6, 128, 128)
      const positions = planeGeometry.attributes.position.array
      return positions
    }, [])

    // shaderの設定
    const shaderArgs = useMemo(
      () => ({
        uniforms: {
          uTime: { value: 0 }
        },
        vertexShader,
        fragmentShader
      }),
      []
    )

    const vec = new THREE.Vector3()
    useFrame((state) => {
      shaderArgs.uniforms.uTime.value++
      // カメラを徐々に手前に遠ざける
      frame < 3 && (frame += 0.0005)
      state.camera.position.lerp(vec.set(0, 0, frame), 0.1)
      state.camera.updateProjectionMatrix()
    })

    const PlaneGeometry = () => (
      <>
        <bufferGeometry>
          <bufferAttribute attach="attributes-position" array={planePositions} itemSize={3} count={planePositions.length / 3} />
        </bufferGeometry>
        <shaderMaterial args={[shaderArgs]} depthTest={false} depthWrite={false} />
      </>
    )

    // モヤモヤがクロスするよう2つ描画する
    return (
      <>
        <points rotation={[-Math.PI / 3, 1, 3]}>
          <PlaneGeometry />
        </points>
        <points rotation={[-Math.PI / -3, 1, -3]}>
          <PlaneGeometry />
        </points>
      </>
    )
  }

まとめ

いかがでしたでしょうか。
半日触ってみた結果としてはやはり何もわからない状態で急に作ろうとすると難しくて本当に分かりませんでした。(今でもあまりわかっていませんが。。。

今回の作成したものをそのまま活かせる場面ってそこまで多くはないと思いますが、今回の知識を持ってすればReactで何か作成する際の幅が広がるかなと思いましたし、もっと時間を使って深堀りしてたいと思えるような技術でした。