Three.js VideoTexture SP対応

three.jsのvideoTextureが、モバイル端末だと表示されず、ネットワークタブを見ると「読み込み中にエラーが起きました」とだけ書かれていた。

Three.js VideoTexture SP対応

dreiのvideoTextureを参考にするとSPでも動くようになったのでメモ。よくわからないが、ポイントは多分以下

loadedmetadataで「準備完了」にした

以前はcanplaythroughイベントを待っていた。最後まで再生できそうなタイミングで発火するので、モバイルだと遅い・発火しないことがある。

crossOrigin="anonymous"

動画をWebGLのテクスチャとしてGPUに乗せるには、ブラウザが「クロスオリジンでも安全に読める」と判断する必要あり

texture.colorSpaceをレンダラーにあわせる

texture.colorSpace = renderer.outpuColorSpace にすると、レンダラーとテクスチャの色空間の解釈が一致し、安定?

スマホは初回のユーザー操作がないと自動再生ポリシーで失敗する

最初からvideoTextureが見えているようなシーンだと注意。
ユーザーの操作をlistenし、play()する関数を作る。

ほぼ全文

export type CreateVideoTextureOptions = {
  /** このイベントで Promise を解決する(drei 既定: loadedmetadata) */
  unsuspend?: keyof HTMLVideoElementEventMap;
  crossOrigin?: HTMLVideoElement["crossOrigin"];
  muted?: boolean;
  loop?: boolean;
  playsInline?: boolean;
  preload?: HTMLVideoElement["preload"];
  /** WebGLRenderer.outputColorSpace に合わせる(drei: gl.outputColorSpace) */
  outputColorSpace?: THREE.ColorSpace;
  minFilter?: THREE.MinificationTextureFilter;
  magFilter?: THREE.MagnificationTextureFilter;
};

export type VideoTextureResult = {
  texture: THREE.VideoTexture<HTMLVideoElement>;
  video: HTMLVideoElement;
};

function isUnsuspendReady(
  video: HTMLVideoElement,
  unsuspend: keyof HTMLVideoElementEventMap
): boolean {
  switch (unsuspend) {
    case "loadedmetadata":
      return video.readyState >= HTMLMediaElement.HAVE_METADATA;
    case "loadeddata":
    case "canplay":
      return video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
    case "canplaythrough":
      return video.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA;
    default:
      return false;
  }
}

export function createVideoTextureFromSrc(
  src: string,
  options: CreateVideoTextureOptions = {}
): Promise<VideoTextureResult> {
  const {
    unsuspend = "loadedmetadata",
    crossOrigin = "anonymous",
    muted = true,
    loop = true,
    playsInline = true,
    preload = "auto",
    outputColorSpace = THREE.SRGBColorSpace,
    minFilter = THREE.NearestFilter,
    magFilter = THREE.NearestFilter,
  } = options;

  return new Promise((resolve, reject) => {
    const video = document.createElement("video");
    video.crossOrigin = crossOrigin;
    video.muted = muted;
    video.loop = loop;
    video.playsInline = playsInline;
    video.preload = preload;
    video.src = src;

    let settled = false;

    const finish = () => {
      if (settled) return;
      settled = true;
      video.removeEventListener(unsuspend, onUnsuspend);
      video.removeEventListener("error", onError);

      const texture = new THREE.VideoTexture(video);
      texture.colorSpace = outputColorSpace;
      texture.minFilter = minFilter;
      texture.magFilter = magFilter;

      resolve({ texture, video });
    };

    const onUnsuspend = () => {
      finish();
    };

    const onError = () => {
      if (settled) return;
      settled = true;
      video.removeEventListener(unsuspend, onUnsuspend);
      video.removeEventListener("error", onError);
      reject(new Error(`Video load failed: ${src}`));
    };

    video.addEventListener("error", onError);

    if (isUnsuspendReady(video, unsuspend)) {
      queueMicrotask(finish);
    } else {
      video.addEventListener(unsuspend, onUnsuspend, { once: true });
    }
  });
}

最後のisUnsuspendReady()箇所は、「loadedmetadataなどのunsuspendイベントがもう済んでいるか、これから来るか」で処理を分けている。
isUnsuspendReadyがtrueになるときは、ブラウザのキャッシュで動画が即座にメタデータまで読めていて、イベントを登録する前に readyState がもう十分な値になっている場合。
このときloadedmetadataを待っても来ない事があるので、queueMicrotasc(finish)で解決。
falseのときは普通にイベント登録。