Open91

WebXR Raw Camera Access APIのbabylon.jsでの利用について考える

Platform Supportに関しては以下

This will be supported on platforms where Chrome supports AR. Currently, this is only Android. WebView support is planned. There are no technical restrictions specific to this API preventing it from being implemented on other platforms.

ARをサポートしているChromeで、現在はAndroidのみ

モチベとしては、
従来のAPIではプライバシー保護の理由から生のカメラアクセスを許可isていなかったが
他のWebXR APIに影響が出そうとのこと

まずはBabylon.jsで'camera-access'feature付きでimmersive WebARモードに入ってみて
XRSessionなどから直接APIをたたけるか試したほうが良さそう

もしそのあと余力があったらbabylon.jsの規格に基づいて
WebXRFeatureを作ることもできそうだ

自分の持っているChrome for Androidで対応しているのか
TypeScriptで開発できるのか、などいろいろ心配事はある

Babylon.jsでWebXR APIを使うときには、WebXR APIの型定義がある

https://github.com/BabylonJS/Babylon.js/blob/master/src/LibDeclarations/webxr.d.ts

いまちょっと気になるのが、
XRViewerPoseにあるViewからcameraを取り出しているけど
cameraプロパティ定義されてなくない?ってところかな

// ... in rAFcb ...
let viewerPose = xrFrame.getViewerPose(xrRefSpace);
for (const view of viewerPose.views) {
  if (view.camera) {
    // ... handle a view that has a camera image ...
  }
}

現行のBabylon.jsの型定義では、WebGLBindingからカメラのWebGLTextureを取得するメソッドがなさそう

これがない

// ... in rAFcb ...
const cameraTexture = binding.getCameraImage(view.camera);

とりあえず躊躇していても始まらんのでvite-ts+babylonjsで作ってみる

> yarn create vite webxr-raw-camera-access-babylon --template vanilla-ts
yarn add @babylonjs/core@5.0.0-alpha.60

babylonは5.0じゃないとWebGLBindingとかWebXR系の機能が欠けている可能性がある

今日時点の成果は、babylonで豆腐を出して
viteでhttpsの設定をして
immersive-arが実行できたところまでかな

余力があれば、

  • GitHub ActionsでLinter走らせる
  • GitHub PagesもしくはAzure SWAへのデプロイの自動化
  • vscodeで保存時にeslintやprettierのfixを走らせる

adb のリモート接続とchromeのdevice inspectを使ったリモートデバッグができたことを確認した

Babylon.jsのcreateDefaultXRExperienceAsyncでcamera-accessをfeatureとして渡してみたところ、
不明のfeatureとして警告された
これはFeatureManager当たりの実装を見たほうがいいな

WebXR APIでFeatureがenableかどうかを返してくれるメソッドとかあると嬉しいんだけどな

エイヤッと実装してみたら、特にcamera-access APIを使うことなくカメラの内部パラメータを取得できた
え、どゆこと
img

カメラの画像をどうやって取得しようか迷っている
WebGLLayerとかXRWebGLBindingなどから何かしらを取得して
WebGLのAPI使って変換する感じになると思うけど

WebGLFrameBufferを取得して別のcanvasに保存して
それからbase64で取り出すという方式を試したい

https://runebook.dev/ja/docs/dom/webglrenderingcontext/copyteximage2d

Webでカメラ画像ってどう取得しようかなーってずっとやってたけど
やっぱりgetUserMediaを試しておこう

ここらへんAPIが少なくて困っていたイメージがあるんだけど
今見たら意外と色々あったのでその知見を整理していく

まずお決まりのごとく、

const stream = await navigator.mediaDevices.getUserMedia(constraints);

でMediaStreamを取得する。
constraintsはvideoなのかとか解像度とかカメラの向きとか指定できる奴

https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia

そしてMediaStream.getVideoTracks()でビデオトラックとなるMediaStreamTrackオブジェクトを取得する。
そしたらvideoTrackを引数にしてImageCaptureオブジェクトを作成することができる。
ドキュメントのサンプルを見たほうが速い

const input = document.querySelector('input[type="range"]');

var imageCapture;

navigator.mediaDevices.getUserMedia({video: true})
.then(mediaStream => {
  document.querySelector('video').srcObject = mediaStream;

  const track = mediaStream.getVideoTracks()[0];
  imageCapture = new ImageCapture(track);

  return imageCapture.getPhotoCapabilities();
})

https://developer.mozilla.org/ja/docs/Web/API/MediaStream/getVideoTracks

ImageCaptureのドキュメントはこちら

https://developer.mozilla.org/en-US/docs/Web/API/ImageCapture

写真をキャプチャするだけでいい、みたいな用途には良さそう
ImageCapture.grabFrameもしくはtakePhotoでキャプチャ画像を取得できる

grabFrameではBitmapImageオブジェクトが返される
BitmapImageはcanvasのCanvasBitMapImageContextを使って表示出来たりする
つまりgrabFrameでcanvasに画像を表示してcanvas.toDataURLでbase64エンコできる

takePhotoはblobオブジェクトが返される
<img>タブに表示させるようなんだとか
tこれはbase64エンコできるのかな

var takePhotoButton = document.querySelector('button#takePhoto');
var canvas = document.querySelector('canvas');

takePhotoButton.onclick = takePhoto;

function takePhoto() {
  imageCapture.takePhoto().then(function(blob) {
    console.log('Took photo:', blob);
    img.classList.remove('hidden');
    img.src = URL.createObjectURL(blob);
  }).catch(function(error) {
    console.log('takePhoto() error: ', error);
  });
}

canvas要素をいちいち作るのめんどくさいのでoffscreenCanvas使いたいんだけど
こいつには肝心のtoDataURLメソッドがない、悲しい

だからめんどくさいけどcanvasを作るか
blobからbase64エンコすることになるけど
blobの場合pngになってるか心配

カメラのキャプチャはできた
だがしかしカメラのストリームをbabylonと奪い合ってしまって共存するのが無理みたい......ん~~~~

まさかと思って試してみたら、NEXUS 5XのChromeは
camera-accessに対応していたという....マジ??

camera-accessを使って生のCPUイメージの取得に成功
OpenARCloudさんのサンプル通りに変換したらいい感じに動いたけどちょっと重い

Immersalとの連携について考える
現状はApp.tsにほぼすべてのロジックを詰め込んでいるのでリファクタもしたいと思いつつ
とりあえず先に動くのを確認したいとも思う

Immersal REST API用のインターフェースは一通り定義できた
そこからBabylonで使えるように相互変換するロジックが必要になる

Requestはbase64文字列とか内部パラメータなのであまり考える必要はないけど
ResponseはMatrixに変換して姿勢データを取得しなくちゃいけな

Matrix.decompose()が使えそう
ここからダイレクトで位置と回転が取得できるっぽい

const matrix = ~~;
matrix.decompose(map.position, map.rotation);

みたいな感じかな

リクエスト送信時の姿勢にレスポンスで返ってきた姿勢の逆行列をかける必要がある
Babylonのmatrix.multiplyの実装を見たところ、
m1.multiply(m2)はM1M2って意味らしい

処理のフローを整理する

  1. 事前にシーンにはマップ原点に対応するオブジェクトを配置
  2. EnterWebXR
  3. 起動直後?もしくはオフセット時間を置いてRawCameraからテクスチャを取得
  4. 取得時の姿勢を保存
  5. Immersal REST APIへリクエスト
  6. apiからレスポンスを受け取る
  7. レスポンスを変換
  8. マップの姿勢を計算

ワークフローが基本babylonに依存しているから
ライフサイクルを司るクラスにステートとロジックを詰め込んでしまってる肥大化してる
XR系だけでもWebXR Managerとして分離したり
ロジックも静的にしてユーティリティとして分離したい

ここら辺の実装が割と終わった
あとはいい感じのタイミングでキャプチャ画像の処理とImmersalへのリクエストを送る実装をして
どんなレスが返ってくるかを検証する

なんとかオレオレアーキテクチャのバグを取り除き、リクエストに必要なパラメータの取得と変換を突破
そしてImmersal /localizeb64 apiにリクエストが通った

Immersal REST APIはCORSでContent-Typeヘッダを許可していないので
指定するとCORSでブロックされる

次のタスクは部屋のglbもしくは.babylonファイルにしてもいいけど
とりあえず部屋のメッシュモデルを読み込む

そして座標変換をして位置合わせがあっているか確認する

部屋のモデルのいらない部分を削って軽くしたモデルをbabylon.js sandboxで表示してみた
多分うまくいってる

glbインポート、そういえば前に失敗していたような......
そんなことを思っているとやまゆさんが反応してくださった

Viteの静的アセットのURLハンドリングについての内容

https://vitejs.dev/guide/assets.html#importing-asset-as-url

例だと/img.png/assets/img.2d8efhg.pngになってる
この仕様のせいでディレクトリとファイル名を分けて扱わなくちゃいけないSceneLoaderと相性が悪い
結局?urlで取得したパスを処理してディレクトリ名とファイル名に分けるしかないかなぁ

/assets/img.2d8efhg.pngの場合、

path.split("/").slice(0,-1).join("/");

/assetsが取得できる

何とか苦戦しながらもglbインポートに成功
Immersalのモデルは重いのでblenderでメッシュを削りに削ったらオンボロスマホでもなんとか読み込めた......

あとは座標変換ができれば完成、というところまで来たけど
案の定詰まってる

Babylonjs Sandboxとか活用しながらもちょっと使い勝手悪いし
BabylonjsのMatrixの挙動が想定していたのと違ったりしてう~~~~nって感じ

やっぱりビジュアルで確認しながらトライアンドエラーしたい感じなのだが
ここでBabylonjs Editorが使えないか、と思った

部屋のglbモデル読み込んで
Immersalのレスポンスをもとに変換行列を作って試してみたい

既出の問題を2つ解決した

一つ目はモデル読み込みで固まる件
これは.glbをsandboxを使って.babylonに変換しておくことによって解決
Immersalのマップを無加工でも読み込めそうだな?

2つ目はcameraのattachControllerが使えなかった件
base64変換用のcnavasが前面に来ていたせいでした()

camera-accessでカメラ画像からImageDataを取得する処理で
WebGLRenderingContext周りで問題が起きているゆえに、
OnXRFrameでglをいじらないといけなくなってコードが煩雑になっている。

しかしgl contextが必要なのはカメラ画像を取得する部分だけなので
ここをまるっとメソッドで囲ってしまって、コールバックにすればよいのではというアイデア
かつここの処理がかなり重いのでWebWorkerに委託できないかと思った

今リポジトリの名前が
WebXR Raw Camera Access Babylonになってるけど
これは後々わからなくなるのでImmersalって単語を入れたほうがいい(戒め)

chrome://flagsにアクセスしてWebXR Incubationをenableにすると
WebXR Device APIの実験的機能にアクセスできるみたい
Nexus5Xはもともとこれをonにしていたんだなぁ

Pixel 4a 5Gで試したところ、無事camera-accessを使用できたので:超嬉しい:

連続で位置合わせが成功することが分かった
Pixelでデバッグできるからかなり快適

babylon.js editorにマップメッシュモデルを読み込んでみる
(リダクション済み)

これ自体は、まず__root__のy軸回転が180度、zのscaleが-1になってて
それをidentityなEmptyの子にして整合性が保たれている

  public onStart(): void {
    const pos = new Vector3(0.8086, 0.2053, 0.2098);
    const rotMatrix = new Matrix();
    rotMatrix.setRow(0, new Vector4(0.352, 0.1208, 0.9281, 0));
    rotMatrix.setRow(1, new Vector4(0.9345, 0.0102, -0.3557, 0));
    rotMatrix.setRow(2, new Vector4(-0.0524, 0.9926, -0.1093, 0));
    rotMatrix.setRow(3, new Vector4(0.0, 0.0, 0.0, 1.0));

    const rot = Quaternion.FromRotationMatrix(rotMatrix.transpose());

    console.log(pos, rot.toEulerAngles());
  }

で出てきた姿勢をcameraに指定してみると
以下のようになった

それっぽいけどカメラのz回転が90度かかってる、おしい

つまりこれ

    const rot = Quaternion.FromRotationMatrix(rotMatrix.transpose()).multiply(
      Quaternion.RotationAxis(Vector3.Forward(), -Math.PI / 2)
    );

Quaternion.FromRotationMatrix(
      rotMatrix.invert().transpose()
    )

つまり逆行列からの天地という順番でやってみたところ、ニアピンって感じの表示になった

カメラの姿勢はidentity、マップの回転を↑の式から得た

マップ原点でカメラの姿勢を変える時は行列→転置→Quaternion→z軸90度回転Quaternionを掛ける
で対処できた
行列→inverse→転置→Quaternionでマップの逆姿勢っぽいものを得られたが
ここでz軸回転のQuaternionを掛けても何も変わらなかった
ということは、逆行列を得る前もしくは逆行列に対してz軸回転の行列を掛けてから計算する必要がある?

Inspectorを導入してインポートされたモデルを見てみた
やはり階層構造がリセットされていてモデルの見た目も変

__root__というTransformationNodeのtransformationがいい感じに補正されていたやつ
(つまりrotatinoのyが180度、scaleのzが-1になってる)

    const node = new BABYLON.TransformNode('MapRoot', this._scene);
    BABYLON.SceneLoader.ImportMeshAsync('', dirName, fileName, this._scene).then((result) => {
      console.log(result.meshes);
      for (const mesh of result.meshes) {
        mesh.rotation.y = Math.PI;
        mesh.scaling.z = -1.0;
        mesh.setParent(node);
      }
    });

これでMapRootの中に良い感じに調整されたメッシュが子供になった

無理やりだけどカメラ座標系に対するマップ姿勢を計算できた?

        const pos = new BABYLON.Vector3(0.8086, 0.2053, 0.2098).negate();

        const rotMatrix = new BABYLON.Matrix();
        rotMatrix.setRow(0, new BABYLON.Vector4(0.352, 0.1208, 0.9281, 0));
        rotMatrix.setRow(1, new BABYLON.Vector4(0.9345, 0.0102, -0.3557, 0));
        rotMatrix.setRow(2, new BABYLON.Vector4(-0.0524, 0.9926, -0.1093, 0));
        rotMatrix.setRow(3, new BABYLON.Vector4(0.0, 0.0, 0.0, 1.0));

        const enhancementMatrix = BABYLON.Matrix.RotationAxis(BABYLON.Vector3.Up(), Math.PI / 2);

        const rot = BABYLON.Quaternion.FromRotationMatrix(
          rotMatrix.multiply(enhancementMatrix).invert().transpose()
        );

        this.mapRootNode.position = pos;
        this.mapRootNode.rotateAround(
          BABYLON.Vector3.Zero(),
          BABYLON.Vector3.Right(),
          rot.toEulerAngles().x
        );
        this.mapRootNode.rotateAround(
          BABYLON.Vector3.Zero(),
          BABYLON.Vector3.Up(),
          rot.toEulerAngles().y
        );
        this.mapRootNode.rotateAround(
          BABYLON.Vector3.Zero(),
          BABYLON.Vector3.Forward(),
          rot.toEulerAngles().z
        );

カメラの姿勢はIdentityでマップだけが動いている

キャプチャ時のカメラの姿勢をコールバックに渡すことにした

ちゃんとXRSessionがInitされてから5秒後にローカライズという風に指定した
もう位置合わせのリクエストは当たり前のように通るようになったな

ただなぜかリクエスト2回読んじゃってる気がする......

前に出てきたレスポンスだとうまくいくのに
なぜか直近のレスポンスだとy軸で反対になっちゃうというバグがある
何回かリクエストを飛ばしてみて実験しなくてはいけない

あと今のところ一回のキャプチャにつき2回のリクエストが飛んでしまっている
これはXRFrameが頻繁に飛んでくることによるもので
キャプチャの必要があるかのフラグがfalseになる前に違うOnXRFrameコールバックが呼ばれてしまうからだと思われる

最終フェーズに入って、Immersalのレスポンスから直接座標変換までをつなぎこんだ
実際に座標変換してみたところ、なんか微妙に曲がってる
これ前に見たやつだ~~~~~


座標変換のメソッド、ちゃんと確立しなきゃダメだな
毎回こんな感じに傾くので、Immersalのレスポンスは結構一定値を返してるんだけど

ふと、canvasの表示のされ方がおかしいことに気づいた
気づいたというか前から気になっていたんだけど、
これ普通に送信データがこのままになってるので大丈夫なのか心配になった

なぜか1080x2340の範囲のなかで
889x1920の領域だけ画像が表示されていて、左下に寄っている現象
どこで間違えているんだろう

左下に寄っているのはflipの処理によるもので
本来は左上に寄っているものがImageDataに入って
それを上下flipすることによって左下に来ている

XRCameraCaptureUtil.createImageFromTextureの時点で生成されているImageDataが変になっている
このImageDataはUint8Array生成のときから同じ大きさのはず

出てきた
どうやらViewportの解像度を使うのではなくViewのカメラから解像度を取得するのが正確なのかもしれない

実際のviewのサイズはどうやら886x1920だったらしい
(view as any).camera.witthなどによってわかったし
手動でviewPortを作成して噛ませることにより正確な解像度のCamera Imageを取得することが可能になった

なんか位置合わせが改善された気がする
だけどなんかあと少しなんだよなぁ......
調整しなきゃいけないのだろうか

あと、なぜかかなり計算リソースを食うようになってる
スクショをとろうとしてもエラーが出たり、fpsがかなり低下したりしている

そろそろ位置合わせをボタンクリックで発火させたい

もしかしたら、画像の向きに問題があるかもしれない
ARFoundationでやった時に、なぜか画像が左に90度回転した画像を送信していたのを思い出した

直近で試してみたいこと

  • カメラ座標系での変換が合っているか
  • 画像の向きに問題があるのか

記事を書いていて知ったのが、
explanationのGitHubではXRViewやXRWebGLBindingsに対してpartialで型宣言をしていた

partial interface XRView {
  // Non-null iff there exists an associated camera that perfectly aligns with the view:
  [SameObject] readonly attribute XRCamera? camera;
};

interface XRCamera {
  // Dimensions of the camera image:
  readonly attribute long width;
  readonly attribute long height;
};

partial interface XRWebGLBinding {
  // Access to the camera texture itself:
  WebGLTexture? getCameraImage(XRCamera camera);
};

これすればXRViewとかいちいちanyにしなくても型補完が効くかもな

Babylon.jsでWebGLTextureを扱うのに、いかのissueが気になった

https://github.com/BabylonJS/Babylon.js/issues/2906

ここではUint8Arrayとサイズ指定で生成したRawTextureに対して、

rawTexture._texture = webglTexture;
const data = rawTexture.readPixels();

でUint8Arrayを取得するというやり方か?

ログインするとコメントできます