Three.jsによる3Dの描画とHTML elementの透視投影変換を連動させる

## はじめに
3Dモデルを愉快に動かしたり、視点を立体的にぐるっと変えるような映像は、ユーザーに楽しさや面白さを体験させることができます。JavaScriptのライブラリThree.jsを利用すれば、数十行程度のコードで3Dモデルを表示させたり、それを動かしたりするコードを書くことができます。

### Three.jsではできないこと
Three.jsでは指定したcanvasに対して3Dモデルや画像を表示させることができます。canvasというのは、HTMLのcanvasタグで作ることができる映像表示用の要素です。

実はこのcanvasですが、そこで扱う映像の中にHTML elementを加えて表示することが（多分）できません。そのためThree.jsでもHTML elementを扱うことができません。一応の対処方法として、HTML elementを画像に変換し、それをThree.jsで定義する平面に張り付けて表示することは可能です。しかし、それではHTML elementの機能（例えば文字の選択、入力フォーム、リンク、CSSによりデザインを変更する機能など）は失われてしまいます。

ではThree.jsで表示する3Dコンテンツの中に、HTMLの機能を保ったままHTML elementを加える方法はないでしょうか？

本記事では、それを疑似的に可能にする方法について説明します。

追記：後で知ったのですが、「HTMLMesh」というライブラリでも似たことができる模様。ただ、制限はあるみたいです。https://note.com/pubcom/n/n6a33ad32845c

## デモ
まずは、完成品のデモを見てください。
<iframe width="600" height="500" src="https://www.youtube.com/embed/a53-31DDyuc?si=UglLZOK5fsbTXzSq" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

このデモではThree.jsで作成した立方体の部屋の中に、HTML elementで作成した画像や文章を表示しています。そして、視点（カメラ）の位置をぐるっと回して見る対象を変えられるようにしています。

![sample1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563469/602b10dd-435e-d44c-555f-48eabe17d713.png)

## デモコード
Three.jsに加え、xnew.jsというライブラリも使っています。
[デモコード](https://mulsense.github.io/xnew/docs/examples/three/perspective)

素のJavaScriptでも書けますので、その場合は以下の解説を参考に作成してみてください。なお、FireFoxではcssの`transform`の動的な変化がやや不安定なようです。それ以外のブラウザで試すことをお勧めします。

## 基本原理
Three.js用のcanvasとそれとは別にHTML elementのレイヤを用意し、それを並べることで疑似的にThree.jsとHTML elementを連動させます。ここで、HTML elementはCSSの`perspective`や`transorm`などの項目を設定し透視投影変換させることで、Three.jsの見え方に合わせて変形させています。

![sample2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563469/f4df36cf-3499-9d86-d892-d925cffdc45c.png)

## 課題
2つのレイヤのそれぞれの要素を適切な位置や見た目で表示するには、いくつかのパラメータを適切に設定する必要があります。具体的には、以下の２種類のパラメータを考慮する必要があります。
- 内部パラメータ
  カメラの焦点距離や画像中心を表すカメラパラメータ
- 外部パラメータ
  3次元空間とカメラの位置と角度の関係を表す6DoFのパラメータ

上記のパラメータが2つのレイヤで整合していない場合、表示する要素にずれが生じたり、立体感の違和感が発生します。ですので、これらのパラメータが2つのレイヤで同じ値になるようにプログラムを組む必要があります。

## 実装

### レイヤの設定

まず、2つのレイヤを用意します。
- layer1: Three.jsの描画対象となるcanvas
- layer2: canvasの上に重ねるHTML element

```html
<div id="main">
  <canvas id="layer1"></canvas>
  <div id="layer2">
    <div id="target">
      <img src="test.jpg"/>
      <p>test</p>
    </div>
  </div>
</div>
```

全体をまとめたmainは、今回は簡単のため固定サイズの要素とします。
各レイヤは、`position: absokute;`を設定し、2つのレイヤが重なるようにします。また、layer2の内部の要素targetは画面の中心に表示されるように設定しています。
```html
<style>
  #main {
    position: relative;
    width: 600px;
    height: 400px;
  }
  #layer1 {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }
  #layer2 {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;

    display: flex;
  }
  #target {
    justify-content: center;
    align-items: center;
  }
</style>
```


### カメラの内部パラメータの設定
まず、内部パラメータというのは、カメラの個体値のようなもので、焦点距離や画像中心、レンズのひずみ具合などのパラメータを表します。
ざっくりと説明すると、焦点距離と画像中心は立体感の程度を調整するパラメータです。これが変わると、同じ形の立体物でも下の図のように見え方が変わります。

![sample3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563469/dbf5dc6b-7444-743b-029d-5244d361fc2c.png)


厳密にいうと、これに加え「レンズひずみ」によっても物体の見え方は変わりますが、そこまでは再現しない場合が多いです。今回もレンズひずみはなしの場合を考えます。

この記事では、
焦点距離：`fx = fy = 500[px]`
画像中心：`Cx, Cy`はcanvasの中心位置
の場合を考えます。

#### Three.jsの設定
Three.jsではカメラを設定する際に、カメラの縦の画角とアスペクト比を入力します。画角は焦点距離から換算可能です。なお、Three.jsでは画像中心はデフォルトでcanvasの中心位置として計算がされます。
```js
const width = 600;
const height = 400;
const f = 500;

const fov = Math.atan2(height / 2, f) * 2 * 180 / Math.PI;
const camera = new THREE.PerspectiveCamera(fov, width / height);
```

焦点距離と画角の換算は、以下のような三角形でカメラを模擬して考えるとイメージしやすいと思います。

![sample4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563469/7a287f4a-c431-32aa-05d3-446c48ba43ed.png)

#### HTML elementの設定
HTML elementはcssによって内部パラメータを設定します。具体的には以下のように、`perspective`と`perspective-origin`で焦点距離と画像中心を設定します。
```html
<style>
  #layer2 {
    position: absolute;
    width: 100%;
    height: 100%;
    display: flex;
    
    perspective: 500px;
    perspective-origin: center;
  }
</style>
```

### カメラの外部パラメータの設定（回転のみ）
外部パラメータは、3次元空間とカメラの位置と角度の関係を表す6DoFのパラメータです。ただ、いきなりこの6Dof（つまり、3次元の回転と併進）を考えるとややこしくなるため、まずは回転だけを考えます。

3次元の回転のパラメータは、回転行列やクォータニオンなどいろいろな表現方法がありますが、ここでは各軸の回転量を保持するような表現方法を考えます。（値はなんでもよいです。）
```js
const rx = 10; // X軸中心の回転
const ry = 20; // Y軸中心の回転
const rz = 30; // Z軸中心の回転
```
なお、上記の変数は弧度法で表現されるものとします。

#### Three.jsの設定
Three.jsでは、作成したカメラのメンバ変数によって回転のパラメータを設定できます。なお、`camera.rotation`の各メンバ変数はラジアンを想定しているため、弧度法からラジアンに変換する必要があります。
```js
const camera = new THREE.PerspectiveCamera(fov, width / height);
camera.rotation.x = +rx * Math.PI / 180;
camera.rotation.y = -ry * Math.PI / 180;
camera.rotation.z = +rz * Math.PI / 180;
```
なお、Three.jsとHTML elementの変換は、Y軸の方向が違う都合からどちらかのパラメータを負にする必要があります。本記事では、Three.jsのY軸に関わるパラメータの設定を負にするものとします。

#### HTML elementの設定
ここが少し厄介です。まず、以下のように回転パラメータを設定する場合、カメラの回転ではなく、HTML elementを中心とした回転になります。
```js
const element = document.querySelector('#target');
element.style.transform = `
  rotateX(${rx}px) rotateY(${ry}px) rotateZ(${rz}px)
`;
```
下の図でいうと、①に相当する回転です。
![sample5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563469/6c6b6e69-182a-76e2-fcad-f1a0bcfe6028.png)

今回考えたいカメラの回転②にするには、styleを以下のように設定します。
```js
const element = document.querySelector('#target');
element.style.transform = `
  translateZ(+500px)
  rotateX(${rx}px) rotateY(${ry}px) rotateZ(${rz}px)
  translateZ(-500px)
`;
```

これは、「位置を500[px]ずらした後、回転させて、位置をもとに戻す」という操作になります。この操作は回転の中心を変えることを意味していて、回転をHTML elementからカメラを中心としたものに置き換えることができます。

### カメラの外部パラメータの設定（回転→併進）
次は回転と併進の両方を考えます。ただ、回転と併進は順番が重要です。まずは回転させた後に、併進させるパターンを見ていきたいと思います。
具体的には、以下のようなイメージです。
![sample6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563469/8f1757e6-8b21-c65d-9a22-33f423156e89.png)


併進のパラメータは以下のように定義します。
```js
const tx = 40; // X軸方向の併進
const ty = 50; // Y軸方向の併進
const tz = 60; // Z軸方向の併進
```

#### Three.jsの設定
Three.jsでは、回転→併進の順番が初めから想定されているので、普通にカメラのメンバ変数に値を設定するだけでOKです。
```js
const camera = new THREE.PerspectiveCamera(fov, width / height);
camera.rotation.x = +rx * Math.PI / 180;
camera.rotation.y = -ry * Math.PI / 180;
camera.rotation.z = +rz * Math.PI / 180;
camera.position.x = +tx;
camera.position.y = -ty;
camera.position.z = +tz;
```

#### HTML elementの設定
こちらも特別ひねりのない設定でOKです。
```js
const element = document.querySelector('#target');
element.style.transform = `
  translateZ(+500px)
  rotateX(${rx}px) rotateY(${ry}px) rotateZ(${rz}px)
  translateX(${tx}px) translateY(${ty}px) translateZ(${tz}px)
  translateZ(-500px)
`;
```

### カメラの外部パラメータの設定（併進→回転）
次に回転させた後に、併進させるパターンも見ていきたいと思います。
具体的には、以下のようなイメージです。回転と併進の数値が同じでも、回転と併進の順番で動きは変わってくるため、注意が必要になります。
![sample7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3563469/e4a9ec97-f196-f203-168c-de1e5cc16651.png)

#### Three.jsの設定
Three.jsでは、併進→回転の順番でカメラを動かしたい場合、少しややこしくなります。
併進成分を、回転を考慮して加工した後設定する方法が考えられますが、計算がややこしくなるため本記事では省略します。簡単な方法としては、カメラではなく3次元空間全体を併進させることで疑似的にこれを再現することができます。

```js
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(fov, width / height);

scene.position.x = +tx;
scene.position.y = -ty;
scene.position.z = +tz;

camera.rotation.x = +rx * Math.PI / 180;
camera.rotation.y = -ry * Math.PI / 180;
camera.rotation.z = +rz * Math.PI / 180;
```


#### HTML elementの設定
こちらは、回転と併進を逆順にするだけでOKです。
```js
const element = document.querySelector('#target');
element.style.transform = `
  translateZ(+500px)
  translateX(${tx}px) translateY(${ty}px) translateZ(${tz}px)
  rotateX(${rx}px) rotateY(${ry}px) rotateZ(${rz}px)
  translateZ(-500px)
`;
```

## まとめ
本記事では、Three.jsとHTML elementの表示を2つのレイアを重ね合わせることで、Three.jsとHTML elementを連動して表現する方法を解説しました。

皆さんが、3Dの素晴らしいWebサイトを作る助けになれば幸いです。