Top View


Author matsuyama

Three.js と Photorealistic 3D Tiles でみまもりアプリを作ってみた

2025/05/30

開発合宿!

5/16 に開発合宿が今宿の SALT 開催されました!

Google の Photorealistic 3D Tiles を使って何かできないかなー、と考えてたのですが、

社内には長距離ウォーキングに参加する方が結構いるので、どこを歩いているか 3D で見れたら面白いかも、と思い Photorealistic 3D Tiles 上に位置情報の時系列データをプロットするみまもりアプリを作ってみました。

Photorealistic 3D Tiles を使う

Photorealistic 3D Tiles は、高解像度の画像でテクスチャ化された 3D メッシュです。世界中の多くの人口密集地域で、高解像度の 3D 地図を提供しています。

CesiumJS から使われることが多いかと思いますが、個人的に Three.js の方が使い慣れているので 3DTilesRenderer を使って、Three.js で Photorealistic 3D Tiles をレンダリングしました。

Photorealistic 3D Tiles を使うには API キー が必要です。

スクリーンショット

会社近くを歩いたログはこんな感じで見れます!

3D マップ スクリーンショット

緑色のマーカーで居た場所を示しています。

GPS での高度測定は難しいのか、ちょっと空飛んでるみたいになっているところもありますが。。。

主な構成

バックエンド

Amplify Gen 2 で Cognito と DynamoDB を立てています。

DynamoDB の Schema は以下のような感じです。

const schema = a.schema({
  Location: a
    .model({
      userId: a.string().required(),
      timestamp: a.float().required(),
      latitude: a.float().required(),
      longitude: a.float().required(),
      altitude: a.float(),
    })
    .authorization((allow) => [allow.owner()])
    .identifier(["userId", "timestamp"]),
});

フロントエンド

Vite と React で作りました。

Three.js 周りは React Three Fiberを使いました 。

主なバージョン

  • Node.js: 22.14.0
  • Vite: 6.3.5
  • React: 19.1.0
  • TanStack Router: 1.120.3
  • Three.js: 0.176.0
  • React Three Fiber: 9.1.2
  • 3DTilesRenderer: 0.4.9
  • AWS Amplify: 6.14.4

主な実装

3D Tiles の実装

3D Tiles の実装は以下です。

  • API キーは環境変数で渡します。
  • 緯度・経度を引数で渡して tiles を作成します。
  • useEffectcamerarenderer を設定します。
  • useFrame で毎フレーム tiles.update() をします。
import { loadGoogleTiles } from '@/utils/threeUtils';
import { useFrame } from '@react-three/fiber';
import { useThree } from '@react-three/fiber';
import { useEffect, useMemo } from 'react';

type TilesProps = {
  latitude: number;
  longitude: number;
};

export function Tiles({ latitude, longitude }: TilesProps) {
  const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;

  if (!apiKey) {
    throw new Error(
      'Google Maps API key is not set. Please set VITE_GOOGLE_MAPS_API_KEY.',
    );
  }

  const camera = useThree((state) => state.camera);
  const renderer = useThree((state) => state.gl);

  const tiles = useMemo(
    () => loadGoogleTiles(apiKey, latitude, longitude),
    [latitude, longitude],
  );

  useEffect(() => {
    tiles.fetchOptions = {};
    tiles.setCamera(camera);
    tiles.setResolutionFromRenderer(camera, renderer);
    tiles.update();
  }, [tiles, camera, renderer]);

  useFrame((_state, _delta) => {
    tiles.update();
  });

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

Three.js 周りの実装

Tiles を含んだ、3D 周りの実装は、以下のようにしました。

  • カメラの設定、ライトの設定はテキトーです。
  • 複数ユーザに対応できるようにしています。
  • CameraControls では、
    • OrbitControls を使っています。
    • target が指定されるとカメラがその位置に移動するようにします。
  • MarkerWithLine では、
    • マーカーには OctahedronGeometry を使っています。
    • マーカー間を ConnectingLine で結んでいます。
import { CameraControls } from "@/components/three/CameraControls";
import MarkersWithLine from "@/components/three/MarkersWithLine";
import { Tiles } from "@/components/three/Tiles";
import type { Location, UserPosition } from "@/types";
import { Canvas } from "@react-three/fiber";

type Props = {
  location: Location | undefined;
  users: string[] | undefined;
  allUserPositions: Map<string, UserPosition[]> | undefined;
  target: UserPosition | undefined;
  userColors: Map<string, number> | undefined;
  selectedUser: string | undefined;
};

export function MapCanvas({
  location,
  users,
  allUserPositions,
  target,
  userColors,
  selectedUser,
}: Props) {
  return (
    <>
      {location && (
        <Canvas
          camera={{
            position: [
              0,
              location.altitude != null ? location.altitude + 300 : 300,
              300,
            ],
            fov: 60,
            far: 100000,
          }}
        >
          <mesh>
            <ambientLight intensity={1} />
            <directionalLight position={[0, 100000, 0]} intensity={1} />
            <CameraControls target={target?.position} />
            <Tiles
              latitude={location.latitude}
              longitude={location.longitude}
            />
            {users?.map((userId) => (
              <group key={userId}>
                <MarkersWithLine
                  positions={allUserPositions?.get(userId) || []}
                  color={userColors?.get(userId) || "red"}
                  opacity={selectedUser === userId ? 1 : 0.5}
                />
              </group>
            ))}
          </mesh>
        </Canvas>
      )}
    </>
  );
}

データ取得周りの実装

データ取得周りの実装は以下のようにしています。

  • ユーザー毎に降順で位置情報を取得します。
  • 全ての位置情報の平均の緯度・経度・高度を設定し、Tiles の緯度・経度に使います。
  • observeQuery を使って、リアルタイムに追加された位置情報を反映します。
  • locationsToUserPositions では、
import type { Location, LocationData, UserPosition } from "@/types";
import { locationsToUserPositions } from "@/utils/convertUtils";
import type { Schema } from "@amplify/data/resource";
import { generateClient } from "aws-amplify/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Subscription } from "rxjs";

export function useLocations() {
  const client = useMemo(() => generateClient<Schema>(), []);
  const [location, setLocation] = useState<Location>();
  const [allLocations, setAllLocations] = useState<LocationData[]>();
  const [allUserPositions, setAllUserPositions] =
    useState<Map<string, UserPosition[]>>();
  const subscriptionRef = useRef<Subscription>(undefined);
  const nowTimestamp = useMemo(() => new Date().getTime(), []);

  // 全ユーザーの位置情報をリセット
  const resetAllUserPositionsAsync = useCallback(
    async (users: string[]) => {
      const locations = await Promise.all(
        users.map(async (userId) => {
          const { data } = await client.models.Location.list({
            userId: userId,
            sortDirection: "DESC",
          });

          return data;
        })
      );

      const allLocations = locations.flat();
      setAllLocations(allLocations);

      // 平均の緯度・経度を計算
      const averageLat = allLocations.reduce((acc, location) => acc + location.latitude, 0) / allLocations.length;
      const averageLon = allLocations.reduce((acc, location) => acc + location.longitude, 0) / allLocations.length;

      // 平均の高度を計算
      let altCount = 0;
      let averageAlt = 0;
      for (const location of allLocations) {
        if (location.altitude != null) {
          averageAlt += location.altitude;
          altCount++;
        }
      }
      averageAlt /= altCount;

      setLocation({ latitude: averageLat, longitude: averageLon, altitude: averageAlt });
    },
    [client]
  );

  // 位置情報の監視を開始
  const startObserve = useCallback((users: string[]) => {
    if (subscriptionRef.current != null) return;

    subscriptionRef.current = client.models.Location.observeQuery({
      filter: {
        and: [
          {
            timestamp: { ge: nowTimestamp },
            or: users.map((userId) => ({
              userId: { eq: userId },
            })),
          },
        ],
      },
    }).subscribe({
      next: ({ items, isSynced }) => {
        if (!isSynced) return;

        setAllLocations((prev) => {
          if (prev == null) return items;

          return [...prev, ...items];
        });
      },
    });
  }, [client, nowTimestamp]);

  // 位置情報の監視を停止
  const stopObserve = useCallback(() => {
    if (subscriptionRef.current == null) return;

    subscriptionRef.current.unsubscribe();
    subscriptionRef.current = undefined;
  }, []);

  // 位置情報を更新
  useEffect(() => {
    if (location == null || allLocations == null) return;

    const positions = locationsToUserPositions(location, allLocations);
    setAllUserPositions(positions);
  }, [allLocations, location]);

  return {
    location,
    allUserPositions,
    resetAllUserPositionsAsync,
    startObserve,
    stopObserve,
  };
}

データ送信周りの実装

GPS トラッカー使いたかったですが、とりあえず別ページを作って、位置情報を送信しました。

  • TanStack Router で別ページを作成しています。
  • setInterval で定期的に navigator.geolocation.getCurrentPosition を取得・送信します。
import { LOCATION_UPDATE_INTERVAL } from '@/consts';
import type { LocationData } from '@/types';
import type { Schema } from '@amplify/data/resource';
import { createFileRoute } from '@tanstack/react-router';
import { generateClient } from 'aws-amplify/api';
import { getCurrentUser } from 'aws-amplify/auth';
import { useEffect, useMemo, useState } from 'react';

export const Route = createFileRoute('/location/')({
  component: RouteComponent,
});

/* 現在の位置情報を取得 */
function getCurrentPosition(): Promise<GeolocationPosition> {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, {
      enableHighAccuracy: true,
      timeout: LOCATION_UPDATE_INTERVAL,
      maximumAge: 0,
    });
  });
}

/* 位置情報を送信 */
async function sendLocation(
  client: ReturnType<typeof generateClient<Schema>>,
  userId: string,
  position: GeolocationPosition,
) {
  return await client.models.Location.create({
    userId,
    timestamp: position.timestamp,
    latitude: position.coords.latitude,
    longitude: position.coords.longitude,
    altitude: position.coords.altitude,
  });
}

function RouteComponent() {
  const [location, setLocation] = useState<LocationData | null | undefined>();
  const client = useMemo(() => generateClient<Schema>(), []);

  // 位置情報を定期的に更新
  useEffect(() => {
    const intervalId = setInterval(async () => {
      const { userId } = await getCurrentUser();
      const position = await getCurrentPosition();
      const { data, errors } = await sendLocation(client, userId, position);
      if (errors != null) {
        console.error(errors);
      }
      setLocation(data);
    }, LOCATION_UPDATE_INTERVAL);

    return () => {
      clearInterval(intervalId);
    };
  }, [client]);

  return (
    <div>
      <h1>Location</h1>
      <p>latitude: {location?.latitude}</p>
      <p>longitude: {location?.longitude}</p>
    </div>
  );
}

定義した型

以下のような型を使っています。

  • 分かりにくいですが、location は緯度,経度,高度、position は x, y, z 座標を示しています。
import type { Schema } from '@amplify/data/resource';
import type { Vector3 } from 'three';

export interface Location {
  latitude: number;
  longitude: number;
  altitude?: number | null;
}

export type LocationData = Schema['Location']['type'];

export interface UserPosition {
  name: string;
  location: Location;
  position: Vector3;
  timestamp: number;
}

動画

新しいデータが来たら、こんな感じで、リアルタイムに更新されます。

3DマップGIF動画

まとめ

  • 3D マップを使えば、周囲の状況が見えて、距離感とかも分かりやすい!
  • Photorealistic 3D Tiles を使えば簡単に 3D マップが表示できる!
  • React Three Fiber を使えば、簡単に 3D 上にオブジェクトが置ける!
  • Amplify Gen 2 を使えば、リアルタイムデータも楽に反映できる!
matsuyama

matsuyama

Twitter X

最近 Unity 触ってます