iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Three.jsを使って部屋のレイアウトを決めたい

こんにちは。
株式会社iimonでエンジニアをしている遠藤です。

つい最近年が明けたな〜なんて思っていたら、あっという間にもうすぐ3月になりますね。
3月といえば、新生活に向けて引越しをされる方も多いのではないのでしょうか。

ということで、今回は引越しにちなんで(?)Three.jsで試しに簡単なお部屋を作ってみました。

Three.jsとは

手軽にWebページ上に3Dコンテンツを表示するJavaScriptのライブラリです。

環境構築

普段業務でReactを使用しているので、今回はreact-three-fiberを使用してReactで実装していきます。
(Node.jsをインストール済みの前提で進めるので、インストールしていない方は先にインストールしてください。)

以下のコマンドでReactのプロジェクトを作成し、ディレクトリを移動してnpm installします。

npm create vite@latest room-simulator -- --template react-ts
cd room-simulator
npm install

今回使用するライブラリをインストールします。

npm install three @types/three @react-three/fiber @react-three/drei
ライブラリ 用途
@react-three/fiber threejs用のReactレンダラー
@react-three/drei 便利なヘルパーコンポーネント

開発サーバを立ち上げます

npm run dev

ディレクトリ構成

こんな感じのディレクトリ構成にしていきます。

room-simulator/
│── public/
│   ├── models/
│   │   ├── bed.glb
│   │   ├── oven.glb
│   │   ├── refrigerator.glb
│   │   ├── sofa.glb
│   │   ├── table.glb
│   │   ├── tv.glb
│   ├── textures/
|   |   ├── wood.jpg
│── src/
│   ├── components/
│   │   ├── Floor.tsx
│   │   ├── Furniture.tsx
│   │   ├── FurnitureMenu.tsx
│   │   ├── Room.tsx
│   │   ├── Wall.tsx
│   ├── App.tsx
│   ├── main.tsx
│   ├── constants/
│   │   ├── furniture.ts

実装手順

1. 3Dコンテンツを表示するための設定

Three.jsで3Dシーンを表示するためには、以下の三つの基本要素が必要です。

  • Scene: 「舞台」のような役割で、3Dオブジェクトや光を配置する空間
  • Camera: 「視点」の役割で、どこから3D空間を見るかの設定
  • Renderer: Cameraの視点からSceneを画面に描画するもの

@react-three/fiberを使用する際は、CanvasコンポーネントをReactツリーに配置することで、レンダリングに必要なSceneとCameraを設定できます。
また、@react-three/dreiOrbitControlsコンポーネントを追加すると、カメラの視点をマウスやタッチ操作で動かすことができるようになるので入れておきます。

上記を踏まえて、App.tsx を以下のように編集します。
この状態では、まだ何も要素を配置していないので何も表示されません。

import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";

const App = () => {
  return (
    <Canvas style={{ height: "100vh", width: "100vw" }}>
      <OrbitControls />
    </Canvas>
  );
};

export default App;

2. 部屋の作成

とりあえず、部屋っぽい箱を表示させたいので壁と床の要素を作って組み合わせます。

シーンに3Dオブジェクトを表示させるために、meshを追加します。 meshは、
・geometry: オブジェクトの形
・material: オブジェクトの外観(色や質感)
を組み合わせることで表現できる3Dオブジェクトです。

2-1. 壁の作成

src/配下にcomponents/フォルダを作成し、Wall.tsx というファイルを作成します。

mkdir src/components && touch src/components/Wall.tsx

作成したファイルに以下のコードを貼り付けます。
Wall.tsx

type WallProps = {
  position: [number, number, number];
  size: [number, number, number];
};

const Wall = ({ position, size }: WallProps) => {
  return (
    <mesh position={position}>
      <boxGeometry args={size} />
      <meshLambertMaterial color="white" />
    </mesh>
  );
};

export default Wall;

壁なので、立方体を表現できるboxGeometryと、マットな質感を表現できるmeshLambertMaterialというマテリアルを使用してみます。
propsでは壁の位置と大きさを受け取るようにしておきます。

2-2. 床の作成

同様にcomponents/配下にFloor.tsxというファイルを作成します。

touch src/components/Floor.tsx

以下のようにコードを書きます。
Floor.tsx

import { useLoader } from "@react-three/fiber";
import { TextureLoader } from "three";

type FloorProps = {
  position: [number, number, number];
  size: [number, number, number];
};

const Floor = ({ position, size }: FloorProps) => {
  const woodTexture = useLoader(TextureLoader, "/textures/wood.jpg");
  return (
    <mesh position={position}>
      <boxGeometry args={size} />
      <meshStandardMaterial map={woodTexture} roughness={0.8} />
    </mesh>
  );
};

export default Floor;

壁と同様に立方体を表現できるboxGeometryを使用します。
せっかくなので、以下のリンクの画像をテクスチャとして使用させていただき、床っぽさを出してみます。
https://ambientcg.com/view?id=WoodFloor051
jpg形式でダウンロードした画像をpublic > textures 配下に置いてファイル名をwoodにします。
こちらもpropsで床の位置と大きさを受け取ります。

2-3. 部屋の作成

壁と床を組み合わせて部屋を作ります。

components配下にRoom.tsxを作成します。

touch src/components/Room.tsx

以下のようにコードを書きます。
部屋全体の大きさ(幅x高さx奥行き)をpropsで受け取り、そこから床と四方の壁にそれぞれの位置や大きさを渡します。
Room.tsx

import Floor from "./Floor";
import Wall from "./Wall";

type RoomProps = {
  // 部屋の幅x高さx奥行きをpropsで受け取る
  size: { width: number; height: number; depth: number };
};

const Room = ({ size }: RoomProps) => {
  return (
    <>
      {/** 壁も床も厚さは0 */}
      <Floor
        position={[0, -size.height / 2, 0]}
        size={[size.width, 0, size.depth]}
      />
      <Wall
        position={[0, 0, size.depth / 2]}
        size={[size.width, size.height, 0]}
      />
      <Wall
        position={[0, 0, -size.depth / 2]}
        size={[size.width, size.height, 0]}
      />
      <Wall
        position={[size.width / 2, 0, 0]}
        size={[0, size.height, size.depth]}
      />
      <Wall
        position={[-size.width / 2, 0, 0]}
        size={[0, size.height, size.depth]}
      />
    </>
  );
};

export default Room;

2-4. アプリケーションから部屋を呼び出す

App.tsx でRoomコンポーネントを呼び出します。
また、このままでは暗いのでambientLightで全体に光を追加します。

import { Canvas } from "@react-three/fiber";
import Room from "./components/Room";
import { OrbitControls } from "@react-three/drei";

const App = () => {
  return (
    <Canvas
      style={{ height: "100vh", width: "100vw" }}
      camera={{ position: [-5, 30, 20] }}
    >
      <OrbitControls />
      {/* 追加①: 全体に光を当てる */}
      <ambientLight intensity={1} />
      {/* 追加②: Roomを呼び出して部屋を描画 */}
      <Room size={{ width: 50, height: 10, depth: 25 }} />
    </Canvas>
  );
};

export default App;

こんな感じの箱ができます。

3. 家具や家電の配置

部屋の箱ができたので、家具や家電を置いてみます。

3-1. 3Dモデルの用意

家具・家電の3Dのモデルが必要なので用意します。
今回は以下のサイトからダウンロードさせていただきました。
https://sketchfab.com/feed

ダウンロードしたglbファイルをpublic/models/配下に配置します。

3-2. 家具・家電の表示
ダウンロードした家具・家電を表示させます。 今回は、家具・家電の表示 / 非表示を切り替えるために画面上にチェックボックスを表示させておこうと思います。

constants/フォルダを作成し、その配下にfurniture.tsファイルを作成します。

mkdir src/constants/ && touch src/constants/furniture.ts

以下のような感じで、先ほどメニューに表示させる家具・家電の名称とmodels/配下に置いたファイルのpathをマッピングします。

export const FURNITURE_PATH = {
  冷蔵庫: "models/refrigerator.glb",
  オーブン: "models/oven.glb",
  テーブル: "models/table.glb",
  ソファ: "models/sofa.glb",
  テレビ: "models/tv.glb",
  ベッド: "models/bed.glb",
};

components/配下にFurnitureMenu.tsxを作成します。

 touch src/components/FurnitureMenu.tsx

以下のコードを貼り付けて、チェックボックスをクリックすると該当アイテムのpathが配列に追加・削除されるようにします。

import React, { Dispatch, SetStateAction } from "react";
import { FURNITURE_PATH } from "../constants/furniture";

type FurnitureMenuProps = {
  setFurniturePathList: Dispatch<SetStateAction<string[]>>;
};

const FurnitureMenu = ({ setFurniturePathList }: FurnitureMenuProps) => {
  const handleChange = (checked: boolean, path: string) => {
    if (checked) {
      setFurniturePathList((prev) => [...prev, path]);
    } else {
      setFurniturePathList((prev) =>
        prev.filter((furniturePath) => furniturePath !== path),
      );
    }
  };

  return (
    <div
      style={{
        position: "fixed",
        top: 0,
        left: 0,
        padding: 10,
        color: "white",
      }}
    >
      <p>表示する家具・家電</p>
      {Object.keys(FURNITURE_PATH).map((name) => (
        <React.Fragment key={name}>
          <input
            type="checkbox"
            id={name}
            value={name}
            onChange={(e) =>
              handleChange(
                e.target.checked,
                FURNITURE_PATH[name as keyof typeof FURNITURE_PATH],
              )
            }
          />
          <label>{name}</label>
        </React.Fragment>
      ))}
    </div>
  );
};

export default FurnitureMenu;

同様にcomponents/配下にFurniture.tsxを作ります。

 touch src/components/Furniture.tsx

以下のコードで、pathを読み込んで3Dモデルを表示できます。

import { useGLTF } from "@react-three/drei";

type FurnitureProps = {
  path: string;
};

const Furniture = ({ path }: FurnitureProps) => {
  const { scene } = useGLTF(path);
  return <primitive object={scene} />;
};

export default Furniture;

App.tsxを以下のように修正します

import { Canvas } from "@react-three/fiber";
import Room from "./components/Room";
import { OrbitControls } from "@react-three/drei";
import FurnitureMenu from "./components/FurnitureMenu";
import { useState } from "react";
import Furniture from "./components/Furniture";

const App = () => {
  // 追加① 表示中の家具・家電のファイルのpathを管理する状態
  const [furniturePathList, setFurniturePathList] = useState<string[]>([]);
  return (
    <>
      <Canvas
        style={{ height: "100vh", width: "100vw" }}
        camera={{ position: [-5, 30, 20] }}
      >
        <OrbitControls />
        <ambientLight intensity={1} />
        <Room size={{ width: 50, height: 10, depth: 25 }} />
        {/** 追加② 選択した家具・家電を表示 */}
        {furniturePathList.map((path, index) => (
          <Furniture key={index} path={path} />
        ))}
      </Canvas>
      {/** 追加③ 表示する家具・家電のチェックメニューの表示 */}
      <FurnitureMenu setFurniturePathList={setFurniturePathList} />
    </>
  );
};

export default App;

これで、家具・家電を表示することができるようになりました。 FurnitureMenuコンポーネントは3Dのオブジェクトを表示するわけではないので、Canvasの外で呼び出します。

3-3. 家具・家電の位置やサイズの調整
このままだと画面上で家具・家電の位置やサイズの調整ができないので機能を追加します。
Three.jsのTransformControlsというクラスを使用すると、Meshの移動、回転、拡大・縮小が簡単に実装できるようです。
@react-three/dreiTransformControlsはThree.jsのTransformControlsのラッパーで、これでmeshを囲ってあげます。
また、移動、回転、拡大・縮小のモードがあるので、今回はオブジェクトをクリックするとモードが切り替わるようにします。

Furniture.tsxを以下のように書き換えました。

import { useGLTF, TransformControls } from "@react-three/drei";
import { useState, useRef } from "react";

type FurnitureProps = {
  path: string;
  setIsTransforming: (state: boolean) => void;
};

const Furniture = ({ path, setIsTransforming }: FurnitureProps) => {
  const { scene } = useGLTF(path);
  const [mode, setMode] = useState<
    "scale" | "rotate" | "translate" | undefined
  >();
  const transformRef = useRef(null);

  // クリックで次のモードに切り替える
  const changeMode = () => {
    if (mode === "scale") {
      setMode("rotate");
    } else if (mode === "rotate") {
      setMode("translate");
    } else {
      setMode("scale");
    }
  };

  return (
    <TransformControls
      ref={transformRef}
      mode={mode}
      showX={mode !== "rotate"} // Y方向にのみ回転できれば良いので、回転の際は非表示
      showZ={mode !== "rotate"} // Y方向にのみ回転できれば良いので、回転の際は非表示
      onMouseDown={() => setIsTransforming(true)} // 操作開始時にカメラ無効
      onMouseUp={() => setIsTransforming(false)} // 操作終了時にカメラ有効
      onClick={changeMode}
    >
      <primitive object={scene} />
    </TransformControls>
  );
};

export default Furniture;

App.tsxは、家具・家電を操作している際にカメラが一緒に動かないように修正します。

import { Canvas } from "@react-three/fiber";
import Room from "./components/Room";
import { OrbitControls } from "@react-three/drei";
import FurnitureMenu from "./components/FurnitureMenu";
import { useState } from "react";
import Furniture from "./components/Furniture";

const App = () => {
  const [furniturePathList, setFurniturePathList] = useState<string[]>([]);
  // 追加①: 家具・家電の操作中にカメラが動かないように、操作中かどうかを管理する状態
  const [isTransforming, setIsTransforming] = useState(false);
  return (
    <>
      <Canvas
        style={{ height: "100vh", width: "100vw" }}
        camera={{ position: [-5, 30, 20] }}
      >
        {/** 追加②: 操作中はOrbitControlsを無効にする */}
        <OrbitControls enabled={!isTransforming} />
        <ambientLight intensity={1} />
        <Room size={{ width: 50, height: 10, depth: 25 }} />
        {furniturePathList.map((path, index) => (
          <Furniture
            key={index}
            path={path}
            // 追加③ 状態をセットする関数をpropsで渡す
            setIsTransforming={setIsTransforming}
          />
        ))}
      </Canvas>
      <FurnitureMenu setFurniturePathList={setFurniturePathList} />
    </>
  );
};

export default App;

これで表示した家具・家電を画面上で動かせるようになりました。

家具・家電をコネコネして最終的にこんな感じの部屋になりました。

まとめ

家具や家電が扱いづらかったり雑なところもありますが、一応簡単なお部屋を作ることができました。

初めてThree.jsを触ったので、最初はあまり実装のイメージが湧かなかったのですが、単純なものであれば結構簡単に表示できるものなのだなあと感動しました。

ユーザーが画面上でもう少し部屋や家具の色や柄などデザインを弄れるようにしたり、作成したデータを保存できるようにするなど追加したかった機能が色々とあるので、気が向いたらまたやってみようと思います。

最後まで読んでくださりありがとうございます。
また、現在弊社ではエンジニアを募集しています!
この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!
iimon採用サイト / Wantedly / Green

参考文献

threejs.org

r3f.docs.pmnd.rs

mam-mam.net