虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

位置情報 AR にスマホのブラウザだけでチャレンジ

虎の穴ラボおっくんです。暑い夏、いかがお過ごしでしょうか?

この記事は、虎の穴ラボ 夏のアドベントカレンダーの14日目の記事です。

今回のアドベントカレンダーも引き続き「見た目でわかるビジュアルネタ5連発」の第4弾となります。
13日目は、A.M.さんによる「【Go言語】アスキーアートでダンジョンから脱出するゲームを作ってみた」が投稿されました。
15日目は、Mさんによるビジュアルネタ「🔴サーバーにエラーが起きた時にVSCodeを真っ赤にしよう🔴」が公開されますこちらも御覧ください。

発端

去る 2022 年 5 月 11 日 Google I/O で、Geospatial API という位置情報を使用した AR に利用する API が公開されました。

しかしながら ARcore 向けの API なので Chrome で扱えるものではないわけです。
悔しい実に悔しいので、ブラウザでできることだけで位置情報 AR にチャレンジしてみます。

作ってみたものはこちらです。

それでは、実装について解説していきます。

参考

環境

開発、ビルドツール解説

この開発では、クライアントが使用する JavaScript のビルドに、Packup を使用します。
こちらは、Deno で動作する パッケージャーツールです。
インストールから、各操作は次のようになります。

$ deno -V
deno 1.22.3

# インストール
$ deno run -A https://deno.land/x/packup@v0.1.12/install.ts
## 省略
Installing packup command
✅ Successfully installed packup
/usr/local/bin/packup

# 開発サーバー起動
$  packup index.html

# ビルド
$ packup build --dist-dir public index.html

非常に使い易いツールなのでオススメです。
以降のブラウザ向けの実装はすべて packup でのバンドルが行われたものを動作確認しています。

要素技術

要素技術 1 : Canvas の合成

AR をスマホのカメラで行うには、カメラで撮影された映像に 拡張された情報 を「重ね合わせる」必要があります。
ポイントは、カメラの入力を取り扱う Canvas と、CG をレンダリングした Canvas の合成です。
この要素を単体で、確認します。

[index.html]

<html>
  <head>
    <title>Three.js合成テスト</title>
    <style>
      * {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <div id="canvas_area">
      <canvas id="result" width="900" height="900"></canvas>
    </div>
    <script type="text/javascript" src="main.ts"></script>
  </body>
</html>

[main.ts]

import * as THREE from "https://cdn.skypack.dev/three";

let videoSource: HTMLVideoElement | null = null;
let offscreenCanvas: HTMLCanvasElement | null = null;
let viewCanvasContext: HTMLCanvasElement | null = null;

window.onload = async () => {
  [videoSource, offscreenCanvas, viewCanvasContext] = canvasInit();
  threeJsInit(offscreenCanvas);
  await videoSourceInit(videoSource);
  canvasUpdate();
};

function canvasInit() {
  // streamを入力するvideoを作成する
  const videoSource = document.createElement("video");

  // 画像を加工するcanvasを作成する
  const offscreenCanvas: HTMLCanvasElement = <HTMLCanvasElement> (
    document.createElement("canvas")
  );

  // 最終的に取得した画像を表示するcanvasを取得する
  const viewCanvas: HTMLCanvasElement = <HTMLCanvasElement> (
    document.querySelector("#result")
  );
  viewCanvas.height = document.documentElement.clientHeight;
  viewCanvas.width = document.documentElement.clientWidth;

  const viewCanvasContext = viewCanvas.getContext("2d");

  //カメラと中間処理のキャンバスのサイズを、最終的に表示するキャンバスを基準に設定
  offscreenCanvas.width = viewCanvas.width;
  videoSource.videoWidth = viewCanvas.width;
  offscreenCanvas.height = viewCanvas.height;
  videoSource.videoHeight = viewCanvas.height;
  return [videoSource, offscreenCanvas, viewCanvasContext];
}

async function videoSourceInit(exportCanvasElement: HTMLCanvasElement) {
  //カメラを取得/設定
  const stream = await navigator.mediaDevices.getUserMedia({
    video: {
      facingMode: { exact: "environment" },
      width: 1920,
      height: 1080,
    },
  });
  //オブジェクトと関連付ける
  exportCanvasElement.srcObject = stream;
  exportCanvasElement.play();
}

const canvasUpdate = () => {
  // 表示用のCanvas に カメラの映像を書き込み
  viewCanvasContext.drawImage(videoSource, 0, 0);
  // 表示用のCanvas に Three.js で作成したCanvasを書き込み
  viewCanvasContext.drawImage(offscreenCanvas, 0, 0);

  window.requestAnimationFrame(canvasUpdate);
};

// Three.js 関連の処理を集約
function threeJsInit(renderTarget: HTMLCanvasElement) {
  // カメラの視野角 52 は、Google pixel 4 Plus に合わせた
  const camera = new THREE.PerspectiveCamera(
    52,
    document.documentElement.clientWidth /
      document.documentElement.clientHeight,
    0.01,
    1000,
  );
  // カメラの位置は、x=0, y=0, z=0 を設置座標とし、z=-1 すなわちz軸方向に向ける
  camera.position.z = 0;
  camera.lookAt(new THREE.Vector3(0, 0, -1));

  const scene = new THREE.Scene();
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshNormalMaterial();
  const mesh = new THREE.Mesh(geometry, material);

  // メッシュは、z=-3 すなわちz軸方向にあり、カメラの真正面に設置する
  mesh.position.z = -3;
  scene.add(mesh);

  // 引数で与えたThree.jsで作成した画像用のCanvasをレンダリング先に指定しレンダラを作成
  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    canvas: renderTarget,
  });
  renderer.setSize(
    document.documentElement.clientWidth,
    document.documentElement.clientHeight,
  );

  // レンダラの背景は、透明にしておく
  renderer.setClearColor(new THREE.Color("black"), 0);

  // アニメーション
  renderer.setAnimationLoop((time) => {
    mesh.rotation.x = time / 1000;
    mesh.rotation.y = time / 1000;

    renderer.render(scene, camera);
  });
}

アクセスしてみると、次のように表示されます。

動くものを deno deploy で公開していますので、こちらで動作確認いただけます。

https://strong-ant-91.deno.dev/

(カメラへのアクセス権限の要求が有ります。)

要素技術 2 : ブラウザで方角情報にアクセス

スマートフォンを屋外で向けて、AR として CG の表示などを試みるとき必要な情報の一つが方角の情報です。
次に、この要素単体で確認します。

方角情報の取得には、deviceorientationabsolute イベントから取得される情報を取り扱う必要があります。
シンプルに取得と取得情報を列挙し表示を行うと次のような実装になります。

[index.html]

<html>
  <head>
    <title>方角取得テスト</title>
    <style>
      * {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <div id="output"></div>
    <script type="text/javascript" src="main.ts"></script>
  </body>
</html>

[main.ts]

window.addEventListener("deviceorientationabsolute", orientationHandler, true);

function orientationHandler(e: DeviceOrientationEvent) {
  const propaties:string[] = []
  for (var key in e) {
    propaties.push(`${key} = ${e[key]}`)
  }
  const propatiesString = propaties.reduce((pre, cur)=> pre +`\n`+ cur )

  document.getElementById("output").innerText = propatiesString;
}

動かしてみると alpha、beta、gamma の 3 つの値が高速に書き換わるのを確認できます。
と、これでもいいのですが、結局のところどちらを向いているのかを判断することができません。
コンパス相当の方位情報を取得したいので、以下のように実装を修正します。

参考: JavaScript プログラミング講座 - DeviceOrientation Event について

[main.ts]

window.addEventListener("deviceorientationabsolute", orientationHandler, true);

function orientationHandler(e: DeviceOrientationEvent) {
  const propaties: string[] = [];
  for (var key in e) {
    if (["alpha", "beta", "gamma"].includes(key)) {
      propaties.push(`${key} = ${e[key]}`);
    }
  }
  const propatiesString = propaties.reduce((pre, cur) => pre + `\n` + cur);

  const direction = culcDirection(e.alpha, e.beta, e.gamma);

  const viewString = propatiesString + `\n` + `方角:${direction}`;

  document.getElementById("output").innerText = viewString;
}

function culcDirection(alpha: number, beta: number, gamma: number): number {
  const rotY = ((gamma || 0) * Math.PI) / 180;
  const rotX = ((beta || 0) * Math.PI) / 180;
  const rotZ = ((alpha || 0) * Math.PI) / 180;
  const cy = Math.cos(rotY);
  const sy = Math.sin(rotY);
  const sx = Math.sin(rotX);
  const cz = Math.cos(rotZ);
  const sz = Math.sin(rotZ);

  const x = -(sy * cz + cy * sx * sz);
  const y = -(sy * sz - cy * sx * cz);

  const direction = Math.atan2(-x, y) * (180.0 / Math.PI) + 180;
  return direction;
}

動作させた際のスマートフォンでの表示は次のようになります。

南を 0 とした 0~360 の範囲の度数系でスマートフォンを正面に構えた時の方角を取得できるようになりました。
また beta の値が、スマートフォンを垂直に構えた時の値を 90 として、どの方角を向けても上下方向の角度が取得できています。

こちらも動くものを deno deploy で公開していますので、こちらで動作確認いただけます。

https://busy-ferret-12.deno.dev/

要素技術 3:ブラウザで位置情報にアクセス

位置情報 AR というくらいなので、位置情報にアクセスする必要があります。 こちらは、navigator.geolocation.getCurrentPosition() から取得します。

mdn - Geolocation.getCurrentPosition()

シンプルな情報の取得の実装は次のようになります。

[index.html]

<html>
  <head>
    <title>位置情報取得テスト</title>
    <style>
      * {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <div id="output"></div>
    <script type="text/javascript" src="main.ts"></script>
  </body>
</html>

[main.ts]

window.onload = () => {
  if (!navigator.geolocation) return;
  setInterval(getPosition, 1000);
};

function getPosition() {
  navigator.geolocation.getCurrentPosition(onSuccess, onError);
}

function onSuccess(position: GeolocationCoordinates) {
  const propaties: string[] = [];
  for (var key in position.coords) {
    propaties.push(`${key} = ${position.coords[key]}`);
  }
  const propatiesString = propaties.reduce((pre, cur) => pre + `\n` + cur);
  document.getElementById("output").innerText = propatiesString;
}
function onError(error: GeolocationPositionError) {
  const propaties: string[] = [];
  for (var key in error) {
    propaties.push(`${key} = ${error[key]}`);
  }
  const propatiesString = propaties.reduce((pre, cur) => pre + `\n` + cur);
  document.getElementById("output").innerText = propatiesString;
}

こちらを動かすと、現在の自身の位置情報を取得できます。
ですが、実際に欲しいのは、「現在の座標から目標地点の座標への」距離と方向(角度)です。

ということで、geodesyを導入し、目標地点への距離と方角を取得してみます。
実装は以下のように修正となります。

[main.ts]

import LatLon from "https://esm.sh/geodesy@2.4.0/latlon-spherical.js";

window.onload = () => {
  if (!navigator.geolocation) return;
  setInterval(getPosition, 1000);
};

function getPosition() {
  navigator.geolocation.getCurrentPosition(onSuccess, onError);
}

function onSuccess(position: GeolocationCoordinates) {
  const propaties: string[] = [];
  for (var key in position.coords) {
    propaties.push(`${key} = ${position.coords[key]}`);
  }
  const propatiesString = propaties.reduce((pre, cur) => pre + `\n` + cur);

  const { distance, direction } = getDistanceAndDirection(position.coords);

  const viewString =
    propatiesString +
    `\n距離1:${distance}\n方角x:${direction.x}\n方角y:${direction.y}`;

  document.getElementById("output").innerText = viewString;
}
function onError(error: GeolocationPositionError) {
  const propaties: string[] = [];
  for (var key in error) {
    propaties.push(`${key} = ${error[key]}`);
  }
  const propatiesString = propaties.reduce((pre, cur) => pre + `\n` + cur);
  document.getElementById("output").innerText = propatiesString;
}

// こちらの座標は東京ドイツ村周辺
const target = {
  latitude: 35.40564021220976,
  longitude: 140.0539013101807,
  altitude: 45,
};

function getDistanceAndDirection(params: {
  latitude: number;
  longitude: number;
  altitude: number;
}): { distance: number; direction: { x: number; y: number } } {
  const selfPosition = new LatLon(params.latitude, params.longitude);
  const targetPosition = new LatLon(target.latitude, target.longitude);

  // 2座標間距離
  const distance = selfPosition.distanceTo(targetPosition);

  // 2座標間平面方向角度
  const direction = { x: 0, y: 0 };
  direction.x = convert(selfPosition.finalBearingTo(targetPosition));

  // 2座標間垂直方向角度
  const altitudeDiff = target.altitude - params.altitude;
  direction.y = (Math.atan2(distance, -altitudeDiff) * 180) / Math.PI - 90;

  return { distance, direction };
}

// 北を0とした0~360度系を南を0とした0~360度系に変換
function convert(arg: number) {
  return (360 - arg + 180) % 360;
}

こちらも動くものを deno deploy で公開していますので、ご自身の座標を確認してみてください。

https://narrow-trout-58.deno.dev/

(位置情報へのアクセス権限の要求が有ります。)

要素技術 4:Deno Deploy へのデプロイのために

先のそれぞれ 2 つの確認とも動作確認用として、Deno deploy で公開していました。

ビルドしたファイルを配信するための簡単なサーバーを実装します。
実装は以下の通りです。

[server.ts]

import { Application Router } from "https://deno.land/x/oak/mod.ts";

const app = new Application();

app.use(async (context, next) => {
  try {
    await context.send({
      root: `${Deno.cwd()}/public`,
      index: "index.html",
    });
  } catch {
    await next();
  }
});

await app.listen({ port: 8000 });

ファイル配信対象は、/public 以下に設置されたファイルとなります。
ですので packup でビルドするときにも、ビルド結果は /public に出力する必要があります。
アプリケーションに関連したファイル群は次のようになります。

[要素技術 1 : Canvas の合成 のファイル群]

.
|-- public // <= ビルド結果
|   |-- index.8ebd5415219d3701aab5bf22edb4ec37.js
|   `-- index.html
|-- server.ts // ファイル配信サーバー
`-- src // ソースファイル群
    |-- index.html
    `-- main.ts

動画用の調整

要素の技術としては、上述の通りなのですが、実際屋外で試すことを想定し、いくつかの調整をしています。

位置調整のために都度ソースコードを直すわけにもいかないので

位置合わせの調整のために屋外でソースを修正とデプロイを繰り返すのも厳しいので、
目標座標だけを返す API を、Supabase Edge Functions に用意しました。

Supabase Edge Functions は、Deno Deploy + supabase の提供するインフラサービス ともいえるサービスで、 ホビーユースであれば、無償でデータベースが使え、データ自体も supabase 提供の画面で修正が可能です。 こちらに API サーバーを 1 つデプロイします。

実装は以下の通りです。

[supabase/functions/position-api/index.ts]

import { serve } from "https://deno.land/std@0.131.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@1.34.0";

export const supabaseClient = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_ANON_KEY")!
);

const errorResponse = new Response("", {
  headers: { "Content-Type": "application/json" },
  status: 500,
});

serve(async () => {
  const { data } = await supabaseClient.from("positions").select().eq("id", 1);

  if (!Array.isArray(data)) return errorResponse;
  if (data.length !== 1) return errorResponse;

  return new Response(JSON.stringify({ position: data[0] }), {
    headers: { "Content-Type": "application/json" },
  });
});

この改修に伴い、deno deploy で動作させる ファイルの配信サーバーには、目標座標を返す Supabase Edge Functions にある api へのリクエストを中継させるルーティングを追加します。

[server.ts(/api/target を Supabase Edge Functions に問い合わせる版)]

import { Application, Router } from "https://deno.land/x/oak/mod.ts";

const bearerToken = Deno.env.get("SUPABASE_BEARER_TOKEN") as string
const apiEndPoint = Deno.env.get("SUPABASE_API_ENDPOINT") as string

const app = new Application();
const router = new Router();

// GET /api/position は、supabeseから届いた結果を詰めなおして送る
router.get("/api/position", async (ctx) => {
  const result = await fetch(
    apiEndPoint,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization:
          `Bearer ${bearerToken}`,
      },
    }
  );
  const resultJson = await result.json();

  ctx.response.body = resultJson;
  ctx.response.status = 200;
  ctx.response.type = "application/json";
});

app.use(router.routes());
app.use(router.allowedMethods());

app.use(async (context, next) => {
  try {
    await context.send({
      root: `${Deno.cwd()}/public`,
      index: "index.html",
    });
  } catch {
    await next();
  }
});

await app.listen({ port: 8080 });

目標地点がどちらなのかわかりにくい

ここまでの内容からご想像されるかもしれませんが、目標地点から離れれば、表示オブジェクトは小さくなります。
極論としては点になってしまい、わかりにくいものとなってしまうので、目標地点の方向に向けた時は、赤いマーカーを表示するようにしました。

最終実装物

最終的な実装は、以下の通りです。

[ファイル一覧]

.
|-- public
|-- server.ts
`-- src
    |-- api.ts
    |-- canvas.ts
    |-- device_orientation.ts
    |-- index.html
    |-- main.ts
    `-- position.ts

[server.ts]

import { Application, Router, Context } from "https://deno.land/x/oak/mod.ts";

const bearerToken = Deno.env.get("SUPABASE_BEARER_TOKEN") as string;
const apiEndPoint = Deno.env.get("SUPABASE_API_ENDPOINT") as string;

const app = new Application();
const router = new Router();

// GET /api/position は、supabeseから届いた結果を詰めなおして送る
router.get("/api/position", async (ctx: Context) => {
  const result = await fetch(
    apiEndPoint,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${bearerToken}`,
      },
    },
  );
  const resultJson = await result.json();

  ctx.response.body = resultJson;
  ctx.response.status = 200;
  ctx.response.type = "application/json";
});

app.use(router.routes());
app.use(router.allowedMethods());

app.use(async (context:Context, next: ()=> Promise<unknown>) => {
  try {
    await context.send({
      root: `${Deno.cwd()}/public`,
      index: "index.html",
    });
  } catch {
    await next();
  }
});

await app.listen({ port: 8080 });

[src/api.ts]

export async function initialTargetFetch(): Promise<Position> {
  const result = await fetch("/api/position");
  const resultJson = await result.json();
  if (!isPosition(resultJson.position)) {
    throw new Error("Result is not Position");
  }
  return resultJson.position;
}

interface Position {
  latitude: number;
  longitude: number;
  altitude: number;
}

function isPosition(lawArg: unknown): lawArg is Position {
  if (!lawArg) return false;

  const arg = lawArg as { [key: string]: unknown };

  if (!("latitude" in arg)) return false;
  if (typeof arg.latitude !== "number") return false;

  if (!("longitude" in arg)) return false;
  if (typeof arg.longitude !== "number") return false;

  if (!("altitude" in arg)) return false;
  if (typeof arg.altitude !== "number") return false;

  return true;
}

[src/canvas.ts]

import * as THREE from "https://cdn.skypack.dev/three";

import { getDirection } from "./device_orientation.ts";
import { getDistanceTo } from "./position.ts";

let videoSource: HTMLVideoElement | null = null;
let offscreenCanvas: HTMLCanvasElement | null = null;
let effectOffscreenCanvas: HTMLCanvasElement | null = null;
let effectOffscreenCanvasContext: CanvasRenderingContext2D | null = null;
let viewCanvasContext: CanvasRenderingContext2D | null = null;
export function canvasInit() {
  videoSource = document.createElement("video");

  offscreenCanvas = document.createElement("canvas") as HTMLCanvasElement;
  effectOffscreenCanvas = document.createElement("canvas") as HTMLCanvasElement;

  const viewCanvas = document.querySelector("#result") as HTMLCanvasElement;
  viewCanvas.height = document.documentElement.clientHeight;
  viewCanvas.width = document.documentElement.clientWidth;

  viewCanvasContext = viewCanvas.getContext("2d");
  effectOffscreenCanvasContext = effectOffscreenCanvas.getContext("2d");

  offscreenCanvas.width = viewCanvas.width;
  effectOffscreenCanvas.width = viewCanvas.width;
  videoSource.videoWidth = viewCanvas.width;
  offscreenCanvas.height = viewCanvas.height;
  effectOffscreenCanvas.height = viewCanvas.height;
  videoSource.videoHeight = viewCanvas.height;
  return [videoSource, offscreenCanvas, viewCanvasContext];
}

export async function videoSourceInit() {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: {
      facingMode: { exact: "environment" },
      width: 1920,
      height: 1080,
    },
  });
  videoSource.srcObject = stream;
  videoSource.play();
}

export function canvasUpdate() {
  viewCanvasContext.drawImage(videoSource, 0, 0);
  viewCanvasContext.drawImage(offscreenCanvas, 0, 0);
  viewCanvasContext.drawImage(effectOffscreenCanvas, 0, 0);
  window.requestAnimationFrame(canvasUpdate);
}

export function threeJsInit() {
  // カメラの視野角 52 は、Google pixel 4 Plus に合わせた
  const camera = new THREE.PerspectiveCamera(
    52,
    document.documentElement.clientWidth /
      document.documentElement.clientHeight,
    0.01,
    1000,
  );

  camera.position.z = 0;
  camera.lookAt(new THREE.Vector3(0, 0, -1));

  const scene = new THREE.Scene();
  const geometry = new THREE.BoxGeometry(2, 2, 2);
  const material = new THREE.MeshNormalMaterial();
  const mesh = new THREE.Mesh(geometry, material);

  // メッシュは、z=-3 すなわちz軸方向にあり、カメラの真正面に設置する(デフォルト値)
  mesh.position.z = -3;
  scene.add(mesh);

  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    canvas: offscreenCanvas,
  });
  renderer.setSize(
    document.documentElement.clientWidth,
    document.documentElement.clientHeight,
  );

  renderer.setClearColor(new THREE.Color("black"), 0);

  renderer.setAnimationLoop((time:number) => {
    mesh.rotation.x = time / 2000;
    mesh.rotation.y = time / 1000;

    const direction = getDirection();
    const distanceTo = getDistanceTo();
    const diff = convert(distanceTo.direction.x, direction.horizontal);

    mesh.position.z = -distanceTo.distance * Math.cos((diff / 180) * Math.PI);
    mesh.position.x = -distanceTo.distance * Math.sin((diff / 180) * Math.PI);
    mesh.position.y = distanceTo.distance *
      Math.cos(((distanceTo.direction.y - direction.vertical) / 180) * Math.PI);
    renderer.render(scene, camera);

    if (Math.abs(diff) > 10) {
      resetEffectAnimation();
      return;
    }
    doEffectAnimation();
  });
}

function doEffectAnimation() {
  effectOffscreenCanvasContext.fillStyle = "rgba(255, 0, 0)";
  effectOffscreenCanvasContext.fillRect(
    10,
    10,
    effectOffscreenCanvas.width - 20,
    effectOffscreenCanvas.height - 20,
  );
  effectOffscreenCanvasContext.clearRect(
    50,
    50,
    effectOffscreenCanvas.width - 100,
    effectOffscreenCanvas.height - 100,
  );
  effectOffscreenCanvasContext.clearRect(
    150,
    0,
    effectOffscreenCanvas.width - 300,
    effectOffscreenCanvas.height,
  );
  effectOffscreenCanvasContext.clearRect(
    0,
    150,
    effectOffscreenCanvas.width,
    effectOffscreenCanvas.height - 300,
  );
}

function resetEffectAnimation() {
  effectOffscreenCanvasContext.clearRect(
    0,
    0,
    effectOffscreenCanvas.width,
    effectOffscreenCanvas.height,
  );
}

function convert(arg: number, target: number) {
  let diff = arg - target;

  if (diff > 180) {
    diff -= 360;
  }
  if (diff < -180) {
    diff += 360;
  }
  return diff;
}

[src/device_orientation.ts]

export interface Direction {
  horizontal: number;
  vertical: number;
}

function initialDirection() {
  return { horizontal: 0, vertical: 0 };
}

const direction = initialDirection();

export function orientationHandler(e: DeviceOrientationEvent) {
  direction.horizontal = culcDirection(e.alpha, e.beta, e.gamma);
  direction.vertical = e.beta;
}

export function culcDirection(
  alpha: number,
  beta: number,
  gamma: number,
): number {
  const rotY = ((gamma || 0) * Math.PI) / 180;
  const rotX = ((beta || 0) * Math.PI) / 180;
  const rotZ = ((alpha || 0) * Math.PI) / 180;
  const cy = Math.cos(rotY);
  const sy = Math.sin(rotY);
  const sx = Math.sin(rotX);
  const cz = Math.cos(rotZ);
  const sz = Math.sin(rotZ);

  const x = -(sy * cz + cy * sx * sz);
  const y = -(sy * sz - cy * sx * cz);

  return Math.atan2(-x, y) * (180.0 / Math.PI) + 180;
}

export function getDirection() {
  return direction;
}

[src/index.html]

<html lang="ja">
  <head>
    <title>位置情報ARテスト</title>
    <style>
      * {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <div id="canvas_area">
      <canvas id="result" width="0" height="00"></canvas>
    </div>
    <script type="text/javascript" src="main.ts"></script>
  </body>
</html>

[src/main.ts]

import {
  canvasInit,
  canvasUpdate,
  threeJsInit,
  videoSourceInit,
} from "./canvas.ts";
import { orientationHandler } from "./device_orientation.ts";
import { positionHundler, setTarget } from "./position.ts";
import { initialTargetFetch } from "./api.ts";
window.onload = async () => {
  if (!navigator.geolocation) return;
  setInterval(positionHundler, 1000);

  try {
    const target = await initialTargetFetch();
    setTarget(target);
  } catch (e) {
    console.error(e);
  }

  canvasInit();
  threeJsInit();
  await videoSourceInit();
  canvasUpdate();
};

window.addEventListener("deviceorientationabsolute", orientationHandler, true);

[src/position.ts]

import LatLon from "https://esm.sh/geodesy@2.4.0/latlon-spherical.js";

export interface DistanceTo {
  distance: number;
  direction: { x: number; y: number };
}

function initialDistanceTo() {
  return { distance: 1000, direction: 0 };
}

const distanceTo = initialDistanceTo();

export function getDistanceTo() {
  return distanceTo;
}

export function positionHundler() {
  navigator.geolocation.getCurrentPosition(onSuccess, onError);
}

function onSuccess(position: GeolocationCoordinates) {
  const { distance, direction } = getDistanceAndDirection(position.coords);
  distanceTo.distance = distance;
  distanceTo.direction = direction;
}

function onError(error: GeolocationPositionError) {
  console.error(error);
}

function initialTarget() {
  return {
    latitude: 0,
    longitude: 0,
    altitude: 0,
  };
}

let lawTargetPosition = initialTarget();

export function setTarget(params: {
  latitude: number;
  longitude: number;
  altitude: number;
}) {
  lawTargetPosition = params;
}

function getDistanceAndDirection(params: {
  latitude: number;
  longitude: number;
  altitude: number;
}): DistanceTo {
  const q = lawTargetPosition;
  const selfPosition = new LatLon(params.latitude, params.longitude);
  const targetPosition = new LatLon(q.latitude, q.longitude);

  const distance = selfPosition.distanceTo(targetPosition);

  const direction = { x: 0, y: 0 };
  direction.x = convert(selfPosition.finalBearingTo(targetPosition));

  const altitudeDiff = lawTargetPosition.altitude - params.altitude;
  direction.y = (Math.atan2(distance, -altitudeDiff) * 180) / Math.PI - 90;

  return { distance, direction };
}

function convert(arg: number) {
  return (360 - arg + 180) % 360;
}

github でも公開済みです。

https://github.com/Octo8080/position_ar

動作確認

出発その前に

オブジェクトを近隣(とはいえ少し遠く)へ設置します。
今回は、東京都大田区の本門寺公園に設置しました。

オブジェクトの位置は、Supabase Edge Functions でアプリケーションに提供しますので、Table editor を使用し、座標をデータベースに登録します。

やっと出発

それでは、出発です。

約2キロ手前の 第二京浜松原橋からアプリを起動してみました。

こちらがその時の映像です。

目標地点の方向に向けた時、赤くマーカーが表示されています。

現場到着

現場で撮影したのがこちらの映像となります。

座標に基づいてオブジェクトが表示され、左右に画面を振っても必ず階段のところにオブジェクトが表示されています。
上下に振ってもオブジェクトは見かけ上動かずその場に設置され続けています。

なかなか良さそうですが、実はこれは、完全に成功している映像ではありません。

想定している座標に表示されなかったのです。
状況としては、階段の奥の方の座標に置かれているような計算になっています。

本来は、オブジェクトの周りをぐるりと回りながら動作しているものを撮りたかったのでこの後調整を試みましたが、上手く位置決めができませんでした。

力技で位置情報ARをやってみての学び

位置情報は、ブレが大きいです。
それゆえに、現場での確認をした上での確認と調整をしましたが、それでも求めるクオリティには達せなかったのが現実です。

この辺りは、 ARCore の Geospatial API のドキュメントを見ると、今回のものと比べるとピタッと位置合わせがされており、非常に扱いやすいものになっていることがうかがえます。

developers.google.com

まとめ

力技で、スマートフォンのブラウザを使って位置情報ARをやってみました。
力技ゆえの学びも多く、可能なら今後精度を上げることを試みたいです。

今回紹介したアプリは、https://weak-donkey-90.deno.dev/ で公開中です。

都内の適当な座標を設定しておくので、起動してぐるりと回ると、どこかで赤いマーカーが表示されるのは確認できるはずです。

こぼれ話

  • 今回の準備期間の約3週間、途中確認のためスマホをかざしながら屋外で歩いていましたが、傍目に見るとちょっと怪しい人だったかもしれません。
  • 最後の公園の撮影で、十数か所虫に刺されました。夏はこれからです。皆さんもお気をつけください。

P.S.

採用情報

■募集職種
yumenosora.co.jp