Three.jsでプリレンダー映像が同じ位置に重なるように表示する

Web演出の一環でプリレンダー映像からリアルタイムレンダリングへ滑らかに遷移させたいときがあります。この記事ではそのような目的のために、プリレンダー映像とThree.jsのリアルタイムレンダリングを同じ位置に重ねて表示する方法について解説します。

デモとコード全体は以下を参照してください。

- **デモ:** https://aadebdeb.github.io/three-video-overlap-demo/
- **コード:** https://github.com/aadebdeb/three-video-overlap-demo

Three.jsのバージョンはr154でTypeScriptを利用しています。

## Blenderによる映像作成

今回はBlenderを使って映像を作成します。使用したモデルやカメラアニメーションはThree.jsでも使用するのでglb形式で書き出しておきます。

Blenderの操作は本題ではないので割愛しますが、以下のような映像を作成しました。
![video.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/1f877ff4-5786-0aa9-02a1-30f50717a340.gif)

映像は正方形で作成します。以下の図のようにThree.jsのリアルタイムレンダリングと重ねたときに映像のアスペクト比を維持しながら画面が横長ならば上下が切り取られ、画面が縦長ならば左右が切り取られるようにする想定です(CSSの`object-fit: cover;`のような感じです)。

![figure1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/e4a681e6-2e42-8c96-732e-6b9fe0944235.jpeg)

## Three.jsによる実装

Three.jsではBlenderで作成したglbファイルからカメラ、カメラアニメーション、モデルを読み込みます。また映像はVideoTextureとして読み込み、ポストプロセスで重ねます。

映像を重ねるためのポストプロセスシェーダーは次のようになります。画面のアスペクト比を参考に切り取られる方向のUVを中心から引き伸ばしてテクスチャをサンプリングしています。

```ts:TextureOverlapShader.ts
export const TextureOverlapShader = {
  uniforms: {
    'tDiffuse': { value: null },
    'overlappedTexture': { value: null },
    'rate': { value: 0.5 },
    'aspect': { value: 1.0 }
  },
  vertexShader: `
varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
  `,
  fragmentShader: `
uniform sampler2D tDiffuse;
uniform sampler2D overlappedTexture;
uniform float rate;
uniform float aspect;

varying vec2 vUv;

// from: https://github.com/mrdoob/three.js/blob/205e345b4f48f8b560b3b35391fd914cba1997a0/src/renderers/shaders/ShaderChunk/encodings_pars_fragment.glsl.js#L16C23-L16C23
vec4 sRGBToLinear(in vec4 value) {
  return vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.a );
}

void main() {
  vec3 v1 = texture2D(tDiffuse, vUv).rgb;

  vec2 uv = aspect > 1.0
    ? vec2(vUv.x, (vUv.y - 0.5) / aspect + 0.5) // landspace
    : vec2((vUv.x - 0.5) * aspect + 0.5, vUv.y); // portrait
  vec3 v2 = sRGBToLinear(texture2D(overlappedTexture, uv)).rgb;
  gl_FragColor = vec4(mix(v1, v2, rate), 1.0);
}
  `
}
```

```ts
const videoOverlapPass = new ShaderPass(TextureOverlapShader)
videoOverlapPass.uniforms.overlappedTexture.value = new VideoTexture(video)
videoOverlapPass.uniforms.rate.value = 0.5
videoOverlapPass.uniforms.aspect.value = window.innerWidth / window.innerHeight
composer.addPass(videoOverlapPass)
```



Three.jsの[PerspectiveCamera\#fov](https://threejs.org/docs/#api/en/cameras/PerspectiveCamera.fov)は垂直方向のFOVなので、画面が縦長のときは映像のFOVとカメラのFOVが一致します。そのためglbから読み込んだカメラのFOVをそのまま使えば問題ありません。画面が横長のときはカメラの垂直方向のFOVが映像のFOVと一致しないので、画面のアスペクト比から垂直方向のFOVを求める必要があります。

求める垂直方向のFOVを$\theta$、水平方向のFOVを$\phi$、画面のアスペクト比(横幅/縦長)を$a$として、以下のようなカメラから$d$だけ離れた位置における描画領域を考えます。

![figure2_2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/451e9f45-f902-9b47-c9df-d6e144c837bd.jpeg)

描画領域の縦方向の半分長$x$、横方向の半分長$y$は三角関数の公式からそれぞれ以下のようになります。

```math
\displaylines{
x = d\tan\frac{\phi}{2} \\
y = d\tan\frac{\theta}{2}
}
```

画面のアスペクト比$a$と$x$、$y$の関係から、以下のように式を整理すると垂直方向のFOV$\theta$を求めることができます。

```math
\begin{align}
a &= \frac{x}{y} \\
&= \frac{d\tan\frac{\phi}{2}}{d\tan\frac{\theta}{2}} \\
\tan\frac{\theta}{2} &= \frac{\tan\frac{\phi}{2}}{a} \\
\theta &= 2\arctan\frac{\tan\frac{\phi}{2}}{a}
\end{align}
```

実装すると以下のようになります。

```ts
const originalFov = camera.fov
const updateCameraFov = (width: number, height: number) => {
  if (width > height) { // landscape
    const fov = 2.0 * Math.atan(Math.tan(MathUtils.degToRad(originalFov * 0.5)) / (width / height))
    camera.fov = MathUtils.radToDeg(fov)
  } else { // portrait
    camera.fov = originalFov
  }
}
updateCameraFov(window.innerWidth, window.innerHeight)
```

## 結果

青い背景のときがThree.jsによるリアルタイムレンダリング、黄色い背景のときがプリレンダー映像になります。画面サイズを変えても重なるように描画できることが確認できました。
![Animation3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/15be032d-a93a-5b97-cfa5-d8f53439ac81.gif)
若干、リアルタイムレンダリングが先行するようにずれています。これは以下のようにカメラアニメーションの`AnimationMixer`にvideo要素の`currentTime`を設定して同期させるようにしていますが、ここの`currentTime`が正確に取れないのが原因なのかなと思っています...

```ts
mixer.setTime(video.currentTime)
```