TOPIC 12|Three.jsで活用する[2/2]|ReactでThree.jsを扱う
Three.jsとReactとを組み合わせると、ブラウザ上で3Dを動かせるリッチなコンテンツが作れます。ここではReactを用いてPLATEAUの3D都市モデルをThree.js上で表示するインタラクティブなコンテンツの作り方を紹介します。
【目次】
12.4 Reactを用いたThree.jsの応用
12.4.1 Three.jsを宣言的に扱う
12.4.2 3D都市モデルの表示
12.5 事例
12.4 _ Reactを用いたThree.jsの応用
ここでは、ReactからThree.jsを利用して、PLATEAUの3D都市モデルを宣言的に扱う方法を紹介します。フロントエンドフレームワークにはNext.js(https://nextjs.org/)、プログラミング言語にはTypeScript(https://www.typescriptlang.org/)を使います。そしてThree.jsを宣言的に扱うため、React Three Fiber(https://github.com/pmndrs/react-three-fiber)を使います。
ここで利用するライブラリやフレームワークのバージョンは、執筆時最新となる下記のバージョンです。Three.jsやReact Three Fiberは新機能の開発が活発に行われていることや、その他の技術も流動性が高いため、ここで紹介する内容は短期間で変化することに留意ください。
・Three.js r142
・React Three Fiber v8.2.0
・React v18
・Next.js v12
・TypeScript v4.8
12.4.1 _ Three.jsを宣言的に扱う
React Three FiberはThree.jsのための差分検出処理(Reconciler)を提供します。
Reactでは、小文字から始まりドット(.)を間に含まないタグは、コンポーネントではなく組み込み要素(Intrinsic Element)です。組み込み要素には実体となるオブジェクト型が対応し、そのライフサイクルはReconcilerによって管理されます。<div /> の実体がHTMLDivElementであるのと同様に、React Three FiberではThree.jsのすべてのオブジェクト型を組み込み要素の実体として扱うことができます。
組み込み要素はフックを用いて作成するコンポーネントと比較すると、主に2つの違いがあります。
・Three.jsのすべての機能を利用可能である
・React内で扱うことに起因するオーバーヘッドを無視できる
このチュートリアルでは、Three.jsのオブジェクト型を次のように記述します。
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]}>
<meshStandardMaterial color='red' />
</mesh>
これは、変更・破壊処理を除くと、次の逐次的なコードと等価です。
import { BoxGeometry, Mesh, MeshStandardMaterial, Vector3 } from 'three'
const geometry = new BoxGeometry(1, 1, 1)
const material = new MeshStandardMaterial()
material.color = 'red'
const mesh = new Mesh(geometry, material)
mesh.position = new Vector3(0, 0.5, 0)
scene.add(mesh)
これらの振る舞いについての詳細については、公式ドキュメントのObjects, properties and constructor argumentsを参照してください。
12.4.2 _ 3D都市モデルの表示
以上を踏まえ、PLATEAUの3D都市モデルをThree.js上で表示してみましょう。このチュートリアルは、ブラウザ上で次の表示結果を得ることをゴールとします。
まずは、3D都市モデルを表示することを目指します。そして最後に、インタラクティブな操作の例として、マウス操作で点光源を動かすサンプルを作ります。
■ 環境構築
プロジェクト用のディレクトリを作り、次の内容のpackage.jsonファイルを作成します。これは本チュートリアル内で使用しているNPMパッケージへの依存関係の記述です。
{
"private": true,
"resolutions": {
"postprocessing": "~6.28.7"
},
"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",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/three": "^0.141.0",
"next": "^12.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"three": "^0.142.0",
"three-stdlib": "^2.12.1"
},
"devDependencies": {
"@babel/core": "^7.19.3",
"@types/node": "^18.8.3",
"typescript": "^4.8.4"
}
}
リスト12-2 package.json
リスト12-2を配置したディレクトリをカレントディレクトリとし、次のシェルコマンドを実行し、パッケージ群をインストールします。パッケージ群はnode_modules内に保存されます。
npm install
次に、Next.jsのルーティングのためのpagesディレクトリを作成します。
mkdir pages
以上でセットアップは完了です。次のシェルコマンドを実行して開発サーバーを立ち上げます。
next dev
実行したら、ブラウザからhttp://localhost:3000を開いてください。「404: This page could not be found.」が表示されれば、環境構築は完了です。
【メモ】
ポート3000番が利用中の場合は、別のポートが使われることがあります。next devを実行したときに表示されるメッセージを確認してください。
以降では次のディレクトリ構成を想定します。pagesディレクトリとsrcディレクトリを作成し、必要なファイルを都度作成してください。next-env.d.tsとtsconfig.jsonはNext.jsによって自動生成されます。
├── node_modules/
├── pages/
├── src/
├── next-env.d.ts
├── package-lock.json
├── package.json
└── tsconfig.json
■ 3D Tilesの読み込み
3D TilesはglTFとメタデータの集合です。3D Tilesの仕様はシンプルですから、その読み込みを最初から実装することも難しくありません。
[3D Tiles Format Specification(3D Tilesの仕様)]
https://github.com/CesiumGS/3d-tiles/tree/main/specification
ここではまずPLATEAUの3D都市モデル表示を目的とし、NASAのAdvanced Multi-Mission Operations System(AMMOS)が公開している3D Tiles Rendererを用いることにします。名前にレンダラーとありますが、実際には読み込みのコントローラーです。PLATEAUの3D都市モデルにはPLATEAU配信サービス(試験運用)を用います。
[PLATEAU配信サービス(試験運用)]
https://github.com/Project-PLATEAU/plateau-streaming-tutorial
3D Tiles Rendererを用い、React上でPLATEAUの3D都市モデルを読み込む基本的な処理は次のとおりです。
import { TilesRenderer } from '3d-tiles-renderer'
import { useFrame, useThree } from '@react-three/fiber'
import React, { useCallback, useEffect, useRef, useState } from 'react'
export interface PlateauTilesetProps {
path: string
}
export const PlateauTileset: React.FC<PlateauTilesetProps> = ({ path }) => {
const createTiles = useCallback(
(path: string) =>
new TilesRenderer(
`https://plateau.geospatial.jp/main/data/3d-tiles/${path}/tileset.json`
),
[]
)
// 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])
// TilesRenderer と React の状態を同期する。
const camera = useThree(({ camera }) => camera)
const gl = useThree(({ gl }) => gl)
useEffect(() => {
tiles.setCamera(camera)
}, [tiles, camera])
useEffect(() => {
tiles.setResolutionFromRenderer(camera, gl)
}, [tiles, camera, gl])
useFrame(() => {
tiles.update()
})
return <primitive object={tiles.group} />
}
リスト12-3 React上でPLATEAUの3D都市モデルを読み込む基本的な処理
しかしながら、これだけでは都市モデルは適切に表示されません。3D Tilesを二分木状に構成するBatched 3D Model(=glTFとメタデータ)がすべて座標原点に配置されるためです。
PLATEAUが配信している3D Tilesでは、Batched 3D Modelの境界ボックスの中央座標がglTFの拡張フィールドCESIUM_RTCに格納されています(Batched 3D Modelの仕様にはRTC_CENTERに同様の値がありますが、精度に違いがあります。PLATEAUでは使用されていません)。Three.jsのLoaderおよび3D Tiles Rendererはこの値を加味しません。したがって、glTFの読み込み時にこの値をモデルへ適用する必要があります。
これにあたっては、まずThree.jsのGLTFLoaderのプラグインCesiumRTCPluginを作成します。CesiumRTCPluginでは、下記のように、CESIUM_RTCに格納された値をモデルのposition属性に適用するようにします。
import { GLTF, GLTFLoaderPlugin, GLTFParser } from 'three-stdlib'
export class CesiumRTCPlugin implements GLTFLoaderPlugin {
readonly name = 'CESIUM_RTC'
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
result.scene.position.set(...center)
}
return null
}
}
リスト12-4 src/CesiumRTCPlugin.ts
作成したCesiumRTCPluginをThree.jsのGLTFLoaderへ登録し、TilesRendererにハンドラとして追加することで、glTFの読み込み時に上記の処理が実行されるようになります。その部分だけを抜き出して書くと次のようになります。
import { TilesRenderer } from '3d-tiles-renderer'
import { GLTFLoader } from 'three-stdlib'
import { CesiumRTCPlugin } from './CesiumRTCPlugin'
const gltfLoader = new GLTFLoader()
gltfLoader.register(parser => new CesiumRTCPlugin(parser))
const tiles = new TilesRenderer(...)
tiles.manager.addHandler(/\.gltf$/, gltfLoader)
さて、CESIUM_RTCは地球中心を原点とした直交座標系(ECEF)で定義されます。地球の半径は約6400kmで、3D Tilesの座標単位はメートルですから、モデルは原点から数百万ユニット離れた座標に、鉛直方向に傾いて配置されます。
ここでは簡単にするため、地表面を平面で近似できる十分に小さい空間のみを扱うことにし、扱いやすい座標系に変形します。Y座標を高度とし、基準となるタイルの境界ボックスの底面の中央が原点となるように変形すれば、3D都市モデルを原点近くでXZ平面をおおよその地表面とするように配置できます。これは、境界ボックスの底面の中央がVector3型のcenterとして与えられたとき、
import { Quaternion, Vector3 } from 'three'
const direction = center.clone().normalize()
const up = new Vector3(0, 1, 0)
const rotation = new Quaternion()
rotation.setFromUnitVectors(direction, up)
const offset = new Vector3(0, -center.length(), 0)
のrotationとoffsetをそれぞれquaternionとpositionプロパティに与えたGroupを作成し、その中にタイルを配置することで変形できます。
この変形を行うコンポーネントを次のように定義します。
import React, {
ReactNode,
createContext,
useCallback,
useMemo,
useState
} from 'react'
import { Quaternion, Vector3 } from 'three'
export const PlateauTilesetTransformContext = createContext({
setCenter: (center: Vector3): void => {}
})
export interface PlateauTilesetTransformProps {
children: ReactNode
}
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>
)
}
リスト12-5 src/PlateauTilesetTransform.tsx
そして、リスト12-5に示した3D Tilesの読み込み処理のすべてを含むコンポーネントPlateauTilesetを、次のように記述します。
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 { CesiumRTCPlugin } from './CesiumRTCPlugin'
import { PlateauTilesetTransformContext } from './PlateauTilesetTransform'
const gltfLoader = new GLTFLoader()
gltfLoader.register(parser => new CesiumRTCPlugin(parser))
const material = new MeshStandardMaterial({
metalness: 0.5
})
export interface PlateauTilesetProps {
path: string
center?: boolean
}
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.getOrientedBounds(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} />
}
リスト12-6 src/PlateauTileset.tsx
■ 3D Tilesの表示
以上で作成したコンポーネントをページ内に表示します。
まず、PlateauTilesetを配置したAppコンポーネントを作成します。ここでは千代田区と中央区の建築物モデルを配置します。
PLATEAUの3D都市モデルを表示する方法としてThree.jsを選ぶ理由の多くは、WebGLの機能を直接使える低レイヤーの機能が関数として提供されていることや、Three.jsの豊富なライブラリやサンプルコードが使えることです。次のコードではThree.jsのいくつかの機能とライブラリを用いて表現を整えていますが、詳細の解説は省きます。
import { OrbitControls, PerspectiveCamera, Plane } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
import { EffectComposer, SSAO } from '@react-three/postprocessing'
import React from 'react'
import { PlateauTileset } from '../src/PlateauTileset'
import { PlateauTilesetTransform } from '../src/PlateauTilesetTransform'
export const App: React.FC = () => (
<Canvas shadows>
<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>
<Plane
args={[1e5, 1e5]}
position={[0, 12, 0]}
rotation={[-Math.PI / 2, 0, 0]}
receiveShadow
>
<meshStandardMaterial color='white' />
</Plane>
<PlateauTilesetTransform>
<PlateauTileset
path='bldg/13100_tokyo/13101_chiyoda-ku/notexture'
center
/>
<PlateauTileset path='bldg/13100_tokyo/13102_chuo-ku/notexture' />
</PlateauTilesetTransform>
<EffectComposer>
<SSAO intensity={5000} />
</EffectComposer>
</Canvas>
)
リスト12-7 src/App.tsx
次に、このAppコンポーネントを配置したインデックスページを作成します。
import { Global, css } from '@emotion/react'
import { NextPage } from 'next'
import React from 'react'
import { App } from '../src/App'
const Index: NextPage = () => {
return (
<>
<Global
styles={css`
html,
body,
#__next {
width: 100%;
height: 100%;
margin: 0;
}
`}
/>
<App />
</>
)
}
export default Index
リスト12-8 pages/index.tsx
以上でThree.jsでPLATEAUの3D都市モデルを表示するページができました。http://localhost:3000をブラウザ上で開くと次のような画面が表示されます。
なお、【Cesiumで活用する】でも述べたように、単純に平面上にPLATEAUの3D都市モデルを表示すると建物が地表面から浮きますが、ここでは無視します(その対応方法は「6.3.5 地形の調整」で述べたのと同様ですが、Quantized Meshの読み込みとメッシュ化をThree.js上で実装する必要があります)。
■ インタラクションの追加
最後に簡単なインタラクションを入れてみましょう。
ここでは、マウスポインタに追従する点光源を加えます。点光源の位置は、マウスポインタのスクリーン座標とカメラ座標から成るベクトルと、地上100mに位置するXZ 平面が交差する点とします。交差計算にはRaycasterを用います。
useFrameフックを使用すると、引数に渡した関数が次の再描画の前に実行されるようになります。再描画の間隔はおよそ1秒間に30~60回であり、この関数は高頻度で呼び出されます。そこで次の2点に注意します。
① Reactのステートを更新しないこと
② ユーザー定義のオブジェクト型の新規インスタンスを作成せず、あらかじめ作成したインスタンスを再利用すること
①の理由は、Reactのステートの更新が仮想DOMツリー全体の差分検出処理を伴うため、1秒に数十回実行することが非効率であり、そのオーバーヘッドがパフォーマンスを大きく下げるためです。その代わりにRefを介してThree.jsのオブジェクトを直接操作することでオーバーヘッドをゼロにできます(しかしながら局所的に逐次的な記述になります。またuseFrameの引数として渡す関数は、クロージャ内の変数にのみ作用するので注意してください)。
②は、JavaScriptエンジンが充分に高速化した現在でも、頻繁に実行される箇所でのインスタンス作成と破棄は、速やかにヒープ領域を圧迫し、GC(ガーベッジコレクション)の実行頻度を短くし、結果的にパフォーマンスの低下を招くためです。コンポーネント内でuseStateによって作成したインスタンス(次の例ではRaycaster)は、明示的に再作成しない限り、コンポーネントのライフサイクル全体に渡って同一のインスタンスになることが保証されます。
以上を踏まえたコンポーネントを、下記に示します。
import { Sphere } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import React, { useRef, useState } from 'react'
import { ColorRepresentation, Group, Plane, Raycaster, Vector3 } from 'three'
export const Illuminator: React.FC<{
elevation?: number
color?: ColorRepresentation
}> = ({
elevation = 100,
color = '#ff9f46' // T = 2500K
}) => {
const ref = useRef<Group>(null)
const [raycaster] = useState(() => new Raycaster())
const [plane] = useState(() => new Plane(new Vector3(0, 1, 0)))
plane.constant = -elevation
useFrame(({ camera, mouse }) => {
if (ref.current == null) {
return
}
raycaster.setFromCamera(mouse, camera)
raycaster.ray.intersectPlane(plane, ref.current.position)
})
return (
<group ref={ref}>
<pointLight
distance={1000}
intensity={2}
color={color}
castShadow
shadow-radius={20}
shadow-mapSize={[2048, 2048]}
/>
<Sphere args={[5, 32]}>
<meshStandardMaterial emissive={color} emissiveIntensity={10} />
</Sphere>
</group>
)
}
リスト12-9 src/Illuminator.tsx
作成したIlluminatorをAppに配置します。なおここでは、他の光源を削除し、一部の表現を調整しています。
import { OrbitControls, PerspectiveCamera, Plane } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
import { Bloom, EffectComposer, SSAO } from '@react-three/postprocessing'
import { BlendFunction } from 'postprocessing'
import React from 'react'
import { Illuminator } from '../src/Illuminator'
import { PlateauTileset } from '../src/PlateauTileset'
import { PlateauTilesetTransform } from '../src/PlateauTilesetTransform'
export const App: React.FC = () => (
<Canvas shadows>
<fogExp2 attach='fog' color='#d7ecff' density={0.0002} />
<PerspectiveCamera
makeDefault
position={[-1600, 450, -1400]}
near={10}
far={1e5}
/>
<OrbitControls target={[-1200, 0, -800]} />
<Plane
args={[1e5, 1e5]}
position={[0, 12, 0]}
rotation={[-Math.PI / 2, 0, 0]}
receiveShadow
>
<meshStandardMaterial color='white' />
</Plane>
<PlateauTilesetTransform>
<PlateauTileset
path='bldg/13100_tokyo/13101_chiyoda-ku/notexture'
center
/>
<PlateauTileset path='bldg/13100_tokyo/13102_chuo-ku/notexture' />
</PlateauTilesetTransform>
<Illuminator />
<EffectComposer>
<SSAO intensity={3000} blendFunction={BlendFunction.OVERLAY} />
<Bloom intensity={2} />
</EffectComposer>
</Canvas>
)
ブラウザ上で表示した結果を次に示します。マウスポインタを動かすと、光源が追従することが確認できます。
12.5 _ 事例
「Three.js」を使った例としては、以下のようなものがあります。
■ まちづくり学習ツール
【企業名】 | 東日本旅客鉄道株式会社 / インフォ・ラウンジ株式会社 / 株式会社日建設計 / 特定非営利活動法人放課後NPOアフタースクール |
【分野】 | 都市計画・まちづくり |
【対象地域】 | 東京都港区高輪ゲートウェイ駅周辺地域 |
【PLATEAU利用データ】 | 建築物(LOD1、LOD2) |
【他のデータとの掛け合わせ】 | 駅前計画地区3D都市モデル、道路標識等道路付属物3Dデータ・樹木データ、建物テクスチャ(フォトグラメトリー等による取得)、 街の歴史やランドマークなどあらかじめアプリ内にプロットする地物情(空間座標、ラベル、説明等)、ワークショップ参加者の作成した建物データ |
【利用ソフトウェア】 | Three.js、AR.js |
【活用概要】 | 地域の子どもたちを対象として、3D都市モデルを活用したまちづくり学習ツールを開発し、市民参加型まちづくり促進を目指す |
【URL】 | https://www.mlit.go.jp/plateau/use-case/uc22-031/ |
■ 大丸有 Area Management City Index(AMCI)
【企業名】 | PwCアドバイザリー合同会社 / 株式会社アブストラクトエンジン(パノラマティクス) / 一般社団法人大手町・丸の内・有楽町地区まちづくり協議会 |
【分野】 | 都市計画・まちづくり |
【対象地域】 | 東京都千代田区大手町 / 丸の内 / 有楽町地区 |
【PLATEAU利用データ】 | 建築物(LOD1、LOD2) |
【他のデータとの掛け合わせ】 | メンバーポイントアプリデータ (エリア内のポイント発行数、人流、SDGs活動への貢献指標等) |
【利用ソフトウェア】 | Three.js |
【活用概要】 | 3D都市モデルの持つ「一目瞭然」に「エリア」を可視化する特徴を活かしてエリアマネジメント活動のビジュアライゼーションを行い、企業や個人の参加促進を図るプラットフォーム“Area Management City Index(AMCI)”の開発を行う |
【URL】 | https://www.mlit.go.jp/plateau/use-case/uc21-004/ |
【文】
松田聖大(Takram)、大澤文孝
【監修】
松田聖大(Takram)