Next.js App RouterでPLATEAUを利用する

# はじめに
PLATEAUを使ったwebXRアプリ開発のために、Next.jsのApp Routerを使おうと思ったのですが、これまでに使っている記事を見受けられなかったので、備忘録的に残しておきます

基本的には以下の記事の通りです
より詳細な実装について知りたい場合にはそちらの記事をご覧ください

https://www.mlit.go.jp/plateau/learning/tpc12-2/

:::note info
今回のプロジェクトではPWAとwebXRを導入しているので、それらについての記載が混ざっているかもしれません
:::

# デモ
こんな感じで視点がくるくる移動できるようなものを作ります
<iframe width="950" height="543" src="https://www.youtube.com/embed/vmwmdkKnqEg" title="PLATEAU +three.js + next.js + App Router Demo movie" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# Next.js,ESLint,Prettier などの設定
https://github.com/kanakanho/next-pwa

この Public template を使っています
自作テンプレートなので抜けがあるかもしれません

デプロイにはVercelを利用しています

# ファイル構造
```
.
├── README.md
├── app
│   ├── _components
│   │   ├── PlateauTileset.tsx
│   │   └── PlateauTilesetTransform.tsx
│   ├── _hooks
│   │   └── PlateauTilesetTransformContext.ts
│   ├── _types
│   │   ├── PlateauTilesetProps.ts
│   │   └── PlateauTilesetTransformProps.ts
│   ├── _utils
│   │   └── CesiumRTCPlugin.ts
│   ├── api
│   │   └── ...
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── next-env.d.ts
├── next.config.mjs
├── node_modules
├── package.json
├── public
│   └── ...
├── tsconfig.json
└── yarn.lock
```

Next.jsでPWAをするための設定については省略しています

_hooksディレクトリの使い方が間違ってるかもしれないです

# 補足：PLATEAU が提供しているデータの利用について
利用に関する情報は以下のレポジトリにて説明されています

https://github.com/Project-PLATEAU/plateau-streaming-tutorial

今回の用途に沿ったデータの利用ついてより、詳しく記した記事を書きましたので合わせてご確認ください

https://qiita.com/kanakanho/items/07f0da84c3b40a2f465d

# 実装

## `package.json`
```json:package.json
{
  "name": "test-next",
  "version": "0.1.0",
  "private": true,
  "resolutions": {
    "postprocessing": "~6.28.7"
  },
  "scripts": {
    "dev": "next dev",
    "dev:https": "next dev --experimental-https",
    "build": "next build",
    "start": "next start",
    "lint": "next lint --dir app",
    "lint:fix": "yarn lint --fix",
    "format": "prettier --write --ignore-path .gitignore './**/*.{js,jsx,ts,tsx,json}'"
  },
  "dependencies": {
    "3d-tiles-renderer": "^0.3.13",
    "@emotion/react": "^11.10.4",
    "@emotion/styled": "^11.10.4",
    "@react-three/drei": "^9.17.1",
    "@react-three/fiber": "^8.2.0",
    "@react-three/postprocessing": "~2.6.2",
    "@react-three/xr": "^5.7.1",
    "next": "14.1.4",
    "next-pwa": "^5.6.0",
    "react": "^18",
    "react-dom": "^18",
    "three": "^0.142.0",
    "three-stdlib": "^2.12.1"
  },
  "devDependencies": {
    "@babel/core": "^7.19.3",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "@types/three": "^0.141.0",
    "@types/webxr": "^0.5.15",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-unused-imports": "^3.1.0",
    "prettier": "^3.2.5",
    "typescript": "^5"
  }
}
```

## `page.tsx`
```tsx:page.tsx
'use client';

import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { EffectComposer, SSAO } from '@react-three/postprocessing';
import React from 'react';
import { PlateauTileset } from './_components/PlateauTileset';
import { PlateauTilesetTransform } from './_components/PlateauTilesetTransform';

const Home: React.FC = () => {
  return (
      <Canvas>
          <fogExp2 attach='fog' color='white' density={0.0002} />
          <PerspectiveCamera makeDefault position={[-1600, 450, -1400]} near={10} far={1e5} />
          <OrbitControls target={[-1200, 0, -800]} />
          <ambientLight intensity={0.5} />
          <directionalLight
            position={[500, 1000, 1000]}
            intensity={1}
            castShadow
            shadow-mapSize={[8192, 8192]}
          >
            <orthographicCamera attach='shadow-camera' args={[-2500, 2500, 2500, -2500, 1, 5000]} />
          </directionalLight>
          <PlateauTilesetTransform>
            <PlateauTileset path='bldg/23100_nagoya/23101_chikusa-ku/notexture' center />
          </PlateauTilesetTransform>
          <EffectComposer>
            <SSAO intensity={5000} />
          </EffectComposer>
      </Canvas>
  );
};

export default Home;
```

### カメラ・レンダリング
```tsx
<PerspectiveCamera makeDefault position={[0,0,0]} near={1} far={1000} />
<OrbitControls target={[-1200, 0, -800]} />
```

`canvas`に描画される要素のレンダリングを行うカメラの設定
three.js単体とは異なり、Reactのコンポーネントのように扱う


#### Props
[`PerspectiveCamera`の`Props`](https://github.com/pmndrs/drei?tab=readme-ov-file#perspectivecamera)
| Prop | summary | Default |
| --- | --- | --- |
| makeDefault | カメラをシステムデフォルトとして登録し、ファイバーはそのカメラでレンダリングを開始する。 | - |
| manual | 手動にすると応答性がなくなるので、アスペクト比を自分で計算する | - |
| children | カメラに追従するか、関数を渡すと撮影時に非表示になります | - |
| frames | レンダリングするフレーム数 | 0 |
| resolution | FBOの解像度 | 256 |
| envMap | 機能的に使用するためのオプションの環境マップ | - |


[`Camera`全体に共通する`Props`](https://github.com/mrdoob/three.js/blob/master/src/cameras/PerspectiveCamera.js)
| Prop | summary | Default |
| --- | ------- | ------- |
| fov | カメラのフラスタムの垂直視野角 | 50 |
| aspect | カメラのフラスタムのアスペクト比 | 1 |
| near | カメラのフラスタムの近い平面 | 0.1 |
| far | カメラのフラスタムの遠い平面 | 2000 |

#### OrbitControls
マウスによるカメラの操作を可能にする
特定オブジェクトを中心としたカメラの回転や、スクロールによる`zoom`の変更など

### ライト
```tsx
<ambientLight intensity={0.5} />
<directionalLight
    position={[500, 1000, 1000]}
    intensity={1}
    castShadow
    shadow-mapSize={[8192, 8192]}
>
    <orthographicCamera
        attach='shadow-camera'
        args={[-2500, 2500, 2500, -2500, 1, 5000]}
        {/* [left?: number, right?: number, top?: number, bottom?: number, near?: number, far?: number]*/}
    />
</directionalLight>
```



## _components
```tsx:PlateauTilesetTransform.tsx
'use client';

import React, { useCallback, useMemo, useState } from 'react';
import { Quaternion, Vector3 } from 'three';
import { PlateauTilesetTransformContext } from '../_hooks/PlateauTilesetTransformContext';
import { PlateauTilesetTransformProps } from '../_types/PlateauTilesetTransformProps';

export const PlateauTilesetTransform: React.FC<PlateauTilesetTransformProps> = ({ children }) => {
  const [{ offset, rotation }, setState] = useState<{
    offset?: Vector3;
    rotation?: Quaternion;
  }>({});

  const setCenter = useCallback((center: Vector3) => {
    const direction = center.clone().normalize();
    const up = new Vector3(0, 1, 0);
    const rotation = new Quaternion();
    rotation.setFromUnitVectors(direction, up);
    setState({
      offset: new Vector3(0, -center.length(), 0),
      rotation,
    });
  }, []);

  const context = useMemo(() => ({ setCenter }), [setCenter]);

  return (
    <PlateauTilesetTransformContext.Provider value={context}>
      <group position={offset} quaternion={rotation}>
        {children}
      </group>
    </PlateauTilesetTransformContext.Provider>
  );
};
```

```tsx:PlateauTileset.tsx
'use client';

import { TilesRenderer } from '3d-tiles-renderer';
import { useFrame, useThree } from '@react-three/fiber';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Box3, Matrix4, Mesh, MeshStandardMaterial, Vector3 } from 'three';
import { GLTFLoader } from 'three-stdlib';

import { PlateauTilesetTransformContext } from '../_hooks/PlateauTilesetTransformContext';
import { PlateauTilesetProps } from '../_types/PlateauTilesetProps';
import { CesiumRTCPlugin } from '../_utils/CesiumRTCPlugin';

const gltfLoader = new GLTFLoader();
gltfLoader.register((parser) => new CesiumRTCPlugin(parser));

const material = new MeshStandardMaterial({
  metalness: 0.5,
});

export const PlateauTileset: React.FC<PlateauTilesetProps> = ({ path, center = false }) => {
  const { setCenter } = useContext(PlateauTilesetTransformContext);
  const centerRef = useRef(center);
  centerRef.current = center;

  const createTiles = useCallback(
    (path: string) => {
      const tiles = new TilesRenderer(
        `https://plateau.geospatial.jp/main/data/3d-tiles/${path}/tileset.json`,
      );

      tiles.manager.addHandler(/\.gltf$/, gltfLoader);

      // `center` が指定されているとき、タイルの境界ボックスの底面の中央を
      // PlateauTilesetTransform の位置として指定する。
      tiles.onLoadTileSet = () => {
        if (centerRef.current) {
          const box = new Box3();
          const matrix = new Matrix4();
          tiles.getOrientedBoundingBox(box, matrix);
          box.min.z = box.max.z = Math.min(box.min.z, box.max.z);
          box.applyMatrix4(matrix);
          const center = new Vector3();
          box.getCenter(center);
          setCenter(center);
        }
      };

      // タイル内のすべてのオブジェクトに影とマテリアルを適用する。
      tiles.onLoadModel = (scene) => {
        scene.traverse((object) => {
          object.castShadow = true;
          object.receiveShadow = true;
          if (object instanceof Mesh) {
            object.material = material;
          }
        });
      };
      return tiles;
    },
    [setCenter],
  );

  // TilesRenderer のライフサイクル
  const [tiles, setTiles] = useState(() => createTiles(path));

  const pathRef = useRef(path);
  useEffect(() => {
    if (path !== pathRef.current) {
      pathRef.current = path;
      setTiles(createTiles(path));
    }
  }, [path, createTiles]);

  useEffect(() => {
    return () => {
      tiles.dispose();
    };
  }, [tiles]);

  const camera = useThree(({ camera }) => camera);
  const gl = useThree(({ gl }) => gl);

  // TilesRenderer と React の状態を同期する。
  useEffect(() => {
    tiles.setCamera(camera);
  }, [tiles, camera]);

  useEffect(() => {
    tiles.setResolutionFromRenderer(camera, gl);
  }, [tiles, camera, gl]);

  useFrame(() => {
    tiles.update();
  });

  return <primitive object={tiles.group} />;
};
```

## _types
```ts:PlateauTilesetProps.ts
export interface PlateauTilesetProps {
  path: string;
  center?: boolean;
}
```

```ts:PlateauTilesetTransformProps.ts
import { ReactNode } from 'react';

export interface PlateauTilesetTransformProps {
  children: ReactNode;
}
```

## _hooks
```ts:PlateauTilesetTransformContext.ts
'use client';

import { createContext } from 'react';
import { Vector3 } from 'three';

export const PlateauTilesetTransformContext = createContext({
  // eslint-disable-next-line no-unused-vars
  setCenter: (center: Vector3): void => {},
});
```

## _utils
```ts:CesiumRTCPlugin.ts
import { GLTF, GLTFLoaderPlugin, GLTFParser } from 'three-stdlib';

export class CesiumRTCPlugin implements GLTFLoaderPlugin {
  readonly name = 'CESIUM_RTC';

  // eslint-disable-next-line no-unused-vars
  constructor(private readonly parser: GLTFParser) {}

  afterRoot(result: GLTF): null {
    if (this.parser.json.extensions?.CESIUM_RTC?.center != null) {
      const center: [number, number, number] = this.parser.json.extensions.CESIUM_RTC.center as [
        number,
        number,
        number,
      ];
      result.scene.position.set(...center);
    }
    return null;
  }
}
```

# 終わりに
three.js と cesium が全くの初心者の状態でしたので、コードの読み込みが足りないのですが、とりあえず動くものになったので記事にしました。本当にとりあえず動くだけですが...。
PLATEAUのAR表示が可能になったので、そこから面白い体験を生み出せるように検証を続けてみようと思います！

# 参考文献

https://www.mlit.go.jp/plateau/learning/tpc12-2/

https://www.mlit.go.jp/plateau/learning/tpc06-1/

https://github.com/Project-PLATEAU/plateau-streaming-tutorial

https://github.com/kanakanho/next-pwa

https://docs.pmnd.rs/react-three-fiber/getting-started/introduction

https://zenn.dev/highgrenade/scraps/7420bda8a48cc2

https://github.com/pmndrs/react-three-fiber

https://github.com/pmndrs/react-xr