5
3

WebXR Depth Sensing Module を使って Occlusion してみた

Posted at

はじめに

先日、株式会社 STYLY にて社内ハッカソンが開催されました。お題は自由ということだったので、どうしようかなと思っていたところ、去年の 12 月に X にて、このようなポストを見かけたのを思い出しました。

内容はフラグを有効化すれば Quest のブラウザで Occlusion ができるというものです。
11 月頃に Depth Sensing を試そうとした時は、エラーで使えなくてしょんぼりしていたのですが、確かにエラーが出ないようになっていたので、本格的に WebXR Depth Sensing Module を触ってみることにしました!

そしてできたものがこちらになります!!

以下のサイトで実際に動いているところを見ることができます。

Demo サイト

Demo サイトは現在(2024.01) Android でしか動作しません。

Quest のブラウザで動作させたかったのですが、うまく depth sensing から texture を取得することができませんでした(出来次第更新しようと思っております)。

この記事の内容は cpu-optimized + luminance-alpha の組み合わせで動くものになっております。

また WebXR Device API, WebXR Depth Sensing Module 共に標準化には至っていません。
WebXR Depth Sensing Module については Demo サイト実装時点では草案段階です。
そのためこの記事の内容は動かなくなる可能性があります。

WebXR Depth Sensing Module を使って Occlusion するまでの流れ

Rendering 方法はよくある Post Processing と同じ流れで Occlusion を実現してみました。

手順は以下です。

  1. WebXR の baseLayer の framebuffer とは別に framebuffer を用意
  2. framebuffer に通常のシーンをレンダリング
  3. Depth Sensing Module から depth data が入った WebGLTexture を取得
  4. baseLayer に 2 の depth buffer と 3 で作成した texture を比較して 2 の color buffer を出力

Occlusion の流れ

いざ、実装!

WebXR Device API や WebXR Depth Sensing Module の仕様について、一番正しいのは W3C の仕様書です。こちらを適宜確認しながら実装を進めます。
また実装時点では仕様書は Draft 状態でしたので仕様が変更になっている可能性があります。

WebXR Depth Sensing Module を有効化する

navigator.xr.requestSession の requiredFeatures (or optionalFeatures) で depth-sensing を指定すると共に、 depthSensing option にリクエストする使用方法やデータ形式を指定することで有効化できます。

requiredFeatures と optionalFeatures の違いとしては、指定した機能が端末のブラウザ(以降ユーザーエージェント)上で対応していない場合、前者ではエラーが投げられ、後者では warning が console に表示される(エラーは発生しない)という違いがあります。

const session = await navigator.xr.requestSession("immersive-ar", {
  requiredFeatures: ["depth-sensing"],
  depthSensing: {
    usagePreference: ["cpu-optimized", "gpu-optimized"],
    dataFormatPreference: ["luminance-alpha", "float32"],
  },
});

depthSensing Option で指定する各パラメーターはでは、深度情報をどのように扱うかを指定します。

usagePreference では深度情報が CPU か GPU どちらで使用されることを意図しているか指定でき、dataFormatPreference では深度情報のデータの形式を指定できます。

それぞれのデータフォーマットが CPU や GPU でどのように振る舞うのかについては仕様書に表が用意されていました。

https://www.w3.org/TR/webxr-depth-sensing-1/#usage-and-formats

W3C, WebXR Depth Sensing の Intended data usage and data formats より

ユーザーエージェントによってサポートされている使用方法やデータ形式は異なります。それぞれ片方しかサポートしていなかったりします。

W3C の仕様書によると requestSessiondepthSensing で指定したものがサポートされていない場合はエラーが投げられるとありますが、 Quest3 では特にエラーは発生せず、こちらの指定とは関係なしに起動していました(Android ではちゃんとエラーが発生しました)。

https://www.w3.org/TR/webxr-depth-sensing-1/#session-configuration

私が確認した各端末の対応フォーマットなどは以下のようになっていました。

端末 depthUsage depthDataFormat
Android cpu-optimized luminance-alpha
Quest3 gpu-optimized float32

depthSensing が有効な XRSession には以下のメソッドが追加されるので Depth の使用方法やデータ形式を確認することができます。

console.log("depth usage:", session.depthUsage);
console.log("depth format: ", session.depthDataFormat);

BaseLayer とは別に framebuffer を用意する

Depth Sensing を有効化することができたので次のステップ framebuffer の作成をしていきます。

レンダリング結果として Color Buffer と Depth Buffer の情報を扱いたいのでそれぞれ texture を作成し、作成した framebuffer の Color Buffer と Depth Buffer にアタッチします。

framebuffer を作成する関数
export const createFramebuffer = (
  gl: WebGL2RenderingContext,
  width: number,
  height: number
) => {
  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);

  const colorTexture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, colorTexture);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA,
    width,
    height,
    0,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    null
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.framebufferTexture2D(
    gl.FRAMEBUFFER,
    gl.COLOR_ATTACHMENT0,
    gl.TEXTURE_2D,
    colorTexture,
    0
  );

  const depthTexture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, depthTexture);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.DEPTH_COMPONENT16,
    width,
    height,
    0,
    gl.DEPTH_COMPONENT,
    gl.UNSIGNED_SHORT,
    null
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.framebufferTexture2D(
    gl.FRAMEBUFFER,
    gl.DEPTH_ATTACHMENT,
    gl.TEXTURE_2D,
    depthTexture,
    0
  );

  return { framebuffer, colorTexture, depthTexture };
};

注意しないといけないのは texture のサイズです。
スマートフォンなどディスプレイが 1 枚の場合は Window のサイズで作成してもまあ問題はないのですが、 HMD のように両目にディスプレイがある場合 Window のサイズでは適切なサイズを取得できません。

そこで、それぞれのディスプレイ用の viewport のサイズに合わせた texture を作成することにします。

それぞれのディスプレイの viewport は XRFrameRequestCallbackXRFrame から XRViewerPose を取得し、その中の XRView から各 viewport が取得できます。

requestAnimationFrame の callback でしか XRFrame にアクセスできないので、ループ毎に framebuffer を作成しないように注意しましょう(めっっっちゃ重くなります)。

ここではスマートフォンか両眼ディスプレイかしか考慮していないので雑に width と height を計算しています。

// reference space について
// https://developer.mozilla.org/ja/docs/Web/API/WebXR_Device_API/Geometry#%E5%8F%82%E7%85%A7%E7%A9%BA%E9%96%93
const refSpace = await session.requestReferenceSpace("local");

session.requestAnimationFrame(onXRFrame);

let framebuffer = null;

const onXRFrame: XRFrameRequestCallback = (time: number, frame: XRFrame) => {
  session.requestAnimationFrame(onXRFrame);

  const pose = frame.getViewerPose(refSpace);
  const glLayer = session.renderState.baseLayer;

  // 複数回作成しないように注意
  if (!framebuffer) {
    // width と height を計算する
    // HMD における viewport(x, y, width, height) の値は以下のようになる(仮の値です)
    // 左目: (0, 0, 200, 200)
    // 右目: (0, 200, 0, 0)
    // ここでは単純に width を足している
    const [w, h] = pose.views.reduce(
      (acc, cur) => {
        // baseLayer における各 view の viewport を取得する
        const vp = glLayer.getViewport(cur);
        if (vp) {
          acc[0] += vp.width;
          acc[1] = vp.height;
        }
        return acc;
      },
      [0, 0]
    );

    // 求めた width と height を使って framebuffer を作成する
    framebuffer = createFramebuffer(_gl, w, h);
  }
};

レンダリング

framebuffer が用意できたところで、レンダリングに入っていきます!
レンダリングも XRView 毎に行います。

大まかな流れ

コードでのレンダリングの流れは以下です。

XRFrameRequestCallback 内で処理
const session = frame.session;

pose.views.forEach((view) => {
  const vp = glLayer.getViewport(view);
  if (vp) {
    gl.viewport(vp.x, vp.y, vp.width, vp.height);

    // 先ほど作成した framebuffer をバインド
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);

    // clear する
    gl.clearColor(0.0, 0.0, 0.0, 0.0);
    gl.clearDepth(1.0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    // 普通にシーンをレンダリングする
    draw(gl, frame, view);

    // baseLayer の framebuffer をバインド
    gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);

    // clear する
    gl.clearColor(0.0, 0.0, 0.0, 0.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Depth Sensing からデータを取得
    const depthData = getDepthData(gl, frame, view);

    // Occlusion する!
    drawOcclusion(gl, framebuffer, depthData, view.eye, ...);
  }
});

深度情報取得部分について詳しく

取得できる深度情報は cpu-optimizedgpu-optimized の場合とで違うので注意が必要です。
今回は WebGLTexture として fragment shader に送る必要があるので取得した深度情報をもとに WebGLTexture を作成します。

export const getDepthData = (
  gl: WebGL2RenderingContext,
  frame: XRFrame,
  view: XRView
) => {
  const session = frame.session;

  let depthTexture: WebGLTexture | null = null;

  if (session.depthUsage === "cpu-optimized") {
    // cpu-optimized の場合
    // https://www.w3.org/TR/webxr-depth-sensing-1/#xr-cpu-depth-info-section
    // XRFrame から深度情報を取得できる
    const depthInfo = frame.getDepthInformation(view);

    if (depthInfo) {
      const w = depthInfo.width;
      const h = depthInfo.height;

      // 深度情報は depthInfo.data に格納されている
      const _dataArr = new Int16Array(depthInfo.data);
      const dataArr = new Array(w * h);
      for (let i = 0; i < w; i++) {
        for (let j = 0; j < h; j++) {
          // そのままだと縦と横が逆になっており、さらに反転していたので調整
          const _idx = w - 1 - i + j * w;
          const idx = h - 1 - j + i * h;
          // depthInfo.rawValueToMeters を掛けることでメートルに変換できる
          // https://www.w3.org/TR/webxr-depth-sensing-1/#xr-depth-info-section
          dataArr[idx] = _dataArr[_idx] * depthInfo.rawValueToMeters;
        }
      }

      // WebGLTexture に変換するために ImageData に変換する
      const colorArray = dataArr
        .map((val) =>
          val <= 0.0 || val >= 1.0 ? Array(4).fill(1.0) : [val, val, val, 1.0]
        )
        .flat()
        .map((val) => val * 255.0);
      const colorBuffer = new Uint8ClampedArray(colorArray);
      const imageData = new ImageData(colorBuffer, h);

      // WebGLTexture に変換
      depthTexture = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, depthTexture);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        imageData
      );
      gl.generateMipmap(gl.TEXTURE_2D);

      rawValueToMeters = depthInfo.rawValueToMeters;
    }
  } else if (session.depthUsage === "gpu-optimized") {
    // gpu-optimized の場合
    // https://www.w3.org/TR/webxr-depth-sensing-1/#xr-gpu-depth-info-section
    // XRWebGLBinding から深度情報を取得できる
    const glBinding = new XRWebGLBinding(session, gl);
    const depthInfo = glBinding.getDepthInformation(view);

    // gpu-optimized の場合は WebGLTexture が取得できる
    // この部分については 2024.01 現在、うまく動作確認ができていません・・・!
    depthTexture = depthInfo.texture;

    rawValueToMeters = depthInfo.rawValueToMeters;
  }

  return {
    depthTexture,
    rawValueToMeters,
  };
};

Occlusion 部分について詳しく

Occlusion 部分のコードです、 js 側では必要な情報を Shader に送る処理を書くくらいであまり変わったことはしていません。

const drawOcclusion = (
  gl: WebGL2RenderingContext,
  screenFramebuffer: CustomFramebuffer,
  depthData: DepthData,
  eye: XREye,
  ...
) => {
  // よくある Post Processing の要領で平行投影を用いて画面いっぱいの Plane を生成します
  // ここでは行列の生成に gl-matrix というライブラリを使用しています。
  // https://github.com/toji/gl-matrix
  const projectionMatrix = mat4.create();
  mat4.ortho(projectionMatrix, -1, 1, 1, -1, 0.1, 1);

  const viewMatrix = mat4.create();
  mat4.lookAt(viewMatrix, [0.0, 0.0, 0.5], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);

  const modelMatrix = mat4.create();

  setOcclusionPositionAttribute(gl, occlusionBuffers, occlusionProgramInfo);
  setOcclusionTextureCoordAttribute(gl, occlusionBuffers, occlusionProgramInfo);

  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, occlusionBuffers.indices);

  gl.useProgram(occlusionProgramInfo.program);

  gl.uniformMatrix4fv(
    occlusionProgramInfo.uniformLocations.projectionMatrix,
    false,
    projectionMatrix,
  );
  gl.uniformMatrix4fv(
    occlusionProgramInfo.uniformLocations.viewMatrix,
    false,
    viewMatrix,
  );
  gl.uniformMatrix4fv(
    occlusionProgramInfo.uniformLocations.modelMatrix,
    false,
    modelMatrix,
  );

  // 生成した Plane に今まで生成してきた texture 達を用いて Occlusion するために shader に必要な情報を渡します。

  // color buffer
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, screenFramebuffer.colorTexture);
  gl.uniform1i(occlusionProgramInfo.uniformLocations.screen, 0);

  // depth buffer
  gl.activeTexture(gl.TEXTURE1);
  gl.bindTexture(gl.TEXTURE_2D, screenFramebuffer.depthTexture);
  gl.uniform1i(occlusionProgramInfo.uniformLocations.screenDepth, 1);

  // depth sensing
  gl.activeTexture(gl.TEXTURE2);
  gl.bindTexture(gl.TEXTURE_2D, depthData.depthTexture);
  gl.uniform1i(occlusionProgramInfo.uniformLocations.depthSensingTexture, 2);

  // shader 側で左右どちらの XRView か判断するための情報です。
  // XRView に eye というプロパティが格納されています。
  const eyeNum = (() => {
    switch (eye) {
      case "left":
        return -1;
      case "right":
        return 1;
      default:
        return 0;
    }
  })();
  gl.uniform1i(occlusionProgramInfo.uniformLocations.eye, eyeNum);

  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};

vertex shader は gl_Position を計算して、fragment shader に texture の座標を渡しているだけなので、ここでは fragment shader のみみていきます。

難しいことはしていません、レンダリングしたシーンの depth と Depth Sensing で得られた depth とを比較して color を決定します。

precision mediump float;

uniform int uEye;
uniform sampler2D uScreen;
uniform sampler2D uScreenDepth;
uniform sampler2D uDepthSensingTexture;

varying highp vec2 vTextureCoord;

void main() {
  vec2 texCoord = vTextureCoord;

  // HMD の場合、右目用と左目用で texture を分割してあげる必要があります。
  if (uEye != 0) {
    texCoord = vec2(texCoord.x / 2.0, texCoord.y);
  }
  if (uEye == 1) {
    texCoord = vec2(texCoord.x + 0.5, texCoord.y);
  }

  vec4 screenColor = texture2D(uScreen,  texCoord);

  float screenDepth = texture2D(uScreenDepth, texCoord).r;
  float sensorDepth = texture2D(uDepthSensingTexture, texCoord).r;

  // 作成した framebuffer の depth と Depth Sensing Module によって取得した depth の値を比較し、
  // 近ければ color buffer の色を、遠ければ透明を渡す。
  gl_FragColor = screenDepth < sensorDepth ? screenColor : vec4(0.0);
}


完成!

上記の工程を経て無事 Occlusion できました!

demo.gif

繰り返しになりますが、以下のサイトで動いているところを見ることができます。

Demo サイト
Android のみ動作確認済みです

その他 Tips

本編には直接関係ないけど、 WebXR 絡みでちょっと詰まったこと

TypeScript で書きたいが TypeError や eslint の  not defined が出てしまうので、どうにかする。

TypeScript で書こうと思うと必ず TypeError の壁にぶち当たります。
仕様が Draft であることもあり、型が準備されていないのです。

でもどうしても TypeScript で書きたい・・・!
そんな時は自分で型を書いてしまいましょう!
幸い W3C の仕様書に型が全部書いてあるのでそれをなぞるだけです!
仕様の理解にもつながるしで一石二鳥!??

例えば、XRSession の requestSession で depthSensing オプションを追加しようとすると、 Object literal may only specify known properties, and 'depthSensing' does not exist in type 'XRSessionInit'. と怒られます。

W3C の仕様書を見に行くと Depth Sensing で追加される型の情報が書いてあります。
https://www.w3.org/TR/webxr-depth-sensing-1/#session-configuration

2.3. Session configuration より
dictionary XRDepthStateInit {
  required sequence<XRDepthUsage> usagePreference;
  required sequence<XRDepthDataFormat> dataFormatPreference;
};
2.3. Session configuration より
partial dictionary XRSessionInit {
  XRDepthStateInit depthSensing;
};

上記の情報をもとに型ファイルを作成すると TypeError が消えます。
書き間違えると不幸になるので注意しましょう。

WebXR.d.ts
export declare global {
  type XRDepthUsage = "cpu-optimized" | "gpu-optimized";
  type XRDepthDataFormat = "luminance-alpha" | "float32";

  interface XRDepthStateInit {
    usagePreference: XRDepthUsage[];
    dataFormatPreference: XRDepthDataFormat[];
  }

  interface XRSessionInit {
    depthSensing?: XRDepthStateInit;
  }
}

ライブラリを使うとライブラリ側でこの辺りも吸収してくれることが多いですが、それでもたまに TypeError に遭遇することがあるので覚えておくと幸せになります。

eslint(flat config)の方は languageOptions.globals でプロパティ名を true にしてあげると出なくなります。 globals に定義されていないものは以下の方法で解決できます。
とはいえ、むやみやたらと握りつぶすのはやめましょう。

const config = {
  languageOptions: {
    globals: {
      ...globals.browser,
      ...globals.node,
      // 以下の様に記述していく
      XRWebGLLayer: true,
    },
  },
};

WebXR 空間に何もレンダリングされない、、XRSession に baseLayer をセットする必要があった!

navigator.xr.requestSession をして session.requestAnimationFrame を呼ぶと全画面表示になります。
画面が明らかに切り替わるので、これで好きなように空間にレンダリングできるぞぉーと思い込んでいました。
が、一向に何もレンダリングされません。
最初は position がおかしいのかな?と思っていました、が違いました。
WebGLContext を XR として使用するためには XR 互換として作成された WebGLContext で XRWebGLLayer を作り XRSession の baseLayer をセットしてあげる必要がありました。

https://immersive-web.github.io/webxr/#contextcompatibility

// option xrCompatible を true にする
const gl = canvas.getContext("webgl2", { xrCompatible: true });

...

// baseLayer にセットする
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });

WebXR 空間のカメラはどうなっているのか、XRSession の Projection Matrix と View Matrix

Projection Matrix と View Matrix は XRView に格納されており、いわゆるカメラの情報はこれをそのまま使います。

XRView ごとに格納されているので両眼ディスプレイの場合も右と左の差を気にする必要はなくなります。

const projectionMatrix = view.projectionMatrix;
// 逆行列を使う必要があることに注意
const viewMatrix = view.transform.inverse.matrix;

おわりに

今回初めて WebXR Device API をそのまま書きました。
当初ライブラリを使って試してみる予定でしたが、 WebXR Depth Sensing Module が草案状態なこともあり、低レイヤー部分でエラーになることが多く、これはもう WebXR Device API の仕様から理解しないと厳しいなとなり、このようになりました。
結果ライブラリに用意されている機能でなんとなく使っていた部分の理解が深まり良い経験になりました。
(でも実際は対応端末も少ないしライブラリに実装されるのを待つ方がいい気はしています)

そんなこんなで、至らぬ部分もあったかもですが、気になった点やおかしな点などがありましたらお気軽にコメントや X 等にてリプライいただければと思います!

宣伝

株式会社 STYLY では Unity エンジニア・サーバーサイドエンジニアを募集しています!!ご応募お待ちしています!!

5
3
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
5
3