2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-05-02

はじめに

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

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

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

デモ

こんな感じで視点がくるくる移動できるようなものを作ります

Next.js,ESLint,Prettier などの設定

この 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 が提供しているデータの利用について

利用に関する情報は以下のレポジトリにて説明されています

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

実装

package.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

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;

カメラ・レンダリング

<PerspectiveCamera makeDefault position={[0,0,0]} near={1} far={1000} />
<OrbitControls target={[-1200, 0, -800]} />

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

Props

PerspectiveCameraProps

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

Camera全体に共通するProps

Prop summary Default
fov カメラのフラスタムの垂直視野角 50
aspect カメラのフラスタムのアスペクト比 1
near カメラのフラスタムの近い平面 0.1
far カメラのフラスタムの遠い平面 2000

OrbitControls

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

ライト

<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

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>
  );
};
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

PlateauTilesetProps.ts
export interface PlateauTilesetProps {
  path: string;
  center?: boolean;
}
PlateauTilesetTransformProps.ts
import { ReactNode } from 'react';

export interface PlateauTilesetTransformProps {
  children: ReactNode;
}

_hooks

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

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表示が可能になったので、そこから面白い体験を生み出せるように検証を続けてみようと思います!

参考文献

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?