[Three.js] transparent再入門

## はじめに

three.jsの透過オブジェクトは、設定値が絡みあって複雑な挙動をします。これらの設定と挙動の関係を検証するために、デモページを作成しました。このデモページで製作上陥りがちな問題を再現し、その原因と解決策を確認します。

### 対象とする環境

*▼package.json*

```json
"three": "0.105.2"
```

three.jsのバージョンが異なる場合、この記事の内容は適用できない場合があります。ご注意ください。

### 対象とする読者

この記事では、すでにthree.jsを利用している方を読者として想定しています。
インストールガイドやシーン、オブジェクトの初期化方法などは省略します。

## デモページ

作成した[デモページ](https://masatomakino.github.io/threejs-lab/demo/StudyDepthTestAndDepthWrite.html)はこちらから。
![](https://www.evernote.com/l/AAkllHVAzFdFPKyVbGX9HQqy6nIfPC0gv3sB/image.png)
上図のような画面が表示されます。左側のcanvas内をドラッグすることでカメラが移動します。右側が各種設定値を変更するコントローラーです。

初期状態では、以下のオブジェクトが表示されています。

-   中央には`outer`メッシュが表示されています。
-   その周りに配置された`satellite`メッシュが表示されています。
-   このふたつの球体はそれぞれ透過しています。

## デモページで発生する問題

このデモページでは、以下の問題が発生しました。それぞれ確認と解決をおこないます。

1.  メッシュの透過失敗
2.  内包されたメッシュの描画失敗

## 問題1 : メッシュの透過失敗

デフォルトの状態のまま、カメラを移動していくと下図のようになります。
![](https://www.evernote.com/l/AAlWLkePT6xITrtJd984UBg5n-HuABi8n9IB/image.png)
透過処理に失敗し、半透明の球体の裏側に入った`satellite`メッシュが欠けています。

### 原因

この描画ミスの原因は、`satellite`メッシュの座標の初期化方法にあります。

```js
const geo = new SphereGeometry(5, 64, 64);
const mat = new MeshPhongMaterial({
    transparent: true,
    opacity: 0.5,
    color: new Color(color)
});
this.satellite = new Mesh(geo, mat);
this.satellite.geometry.translate(30, 0, 0);
                        ^^^^^^^^^
scene.add(this.satellite);
```

satelliteメッシュのジオメトリを変形して、画面中心から移動させています。
`satellite.position.set(30,0,0)`と座標は同じになりますが、深度処理の結果が異なります。

### 想定している状況

[OBJLoader](https://threejs.org/docs/#examples/loaders/OBJLoader)などの外部モデルデータのLoaderを利用すると、この状態が発生します。
Loaderから読み込んだオブジェクトは、position(0,0,0)を原点とするObject3Dに格納されていることがあります。その場合各パーツの座標はジオメトリに直接書き込まれています。この状態で直接Sceneに複数のモデルを追加すると、この問題が起きます。

### 解決方法

ジオメトリではなくメッシュの座標を変更すると、この問題は解決します。

デモページでは、コントローラーの**satellite**タブにあるチェックボックスをONにすると、下記のコードでsatelliteメッシュを再生成します。

```js
const geo = new SphereGeometry(5, 64, 64);
const mat = new MeshPhongMaterial({
    transparent: true,
    opacity: 0.5,
    color: new Color(color)
});
this.satellite = new Mesh(geo, mat);
this.satellite.position.x = 30;
               ^^^^^^^^^
scene.add(this.satellite);
```

![](https://www.evernote.com/l/AAmRe0zOHYhE0aZeCrfKTP5575t3U9W4sOkB/image.png)
無事球体の裏側が描画されました。

## 問題2 : 内包されたメッシュの描画失敗

コントローラーの**Inner Sphere**のx値を`5`に変更し、少しカメラを回転させてください。透過したouterメッシュの内側にinnerメッシュが突如描画されます。
![](https://www.evernote.com/l/AAkY93yw825BFbsUB_ubsdrXubL8jdxq8DUB/image.png)
![](https://www.evernote.com/l/AAki0AFo3rFGb7SCbUkZ8NHN53QxlpZCFh4B/image.png)
この状態では、innerメッシュはouterメッシュに内包されています。

### 原因

この問題はWebGLのdepthTestという機能に起因します。

[wgld.org : カリングと深度テスト](https://wgld.org/d/webgl/w019.html)

この時WebGLがどのような処理を行なっているのか、デモページの実験結果から読み解いてみます。

#### 前提 : GPUの描画処理

まず、GPUがどのように高速描画を行なっているかを大まかに把握しておく必要があります。こちらの動画がGPUの特性をわかりやすく説明しています。

<iframe width="560" height="315" src="https://www.youtube.com/embed/-P28LKWTzrI" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

GPUは画素単位で並列処理を行い高速描画を実現しています。大量のプロセッサがそれぞれの画素を受け持ち、他のプロセッサの処理完了を待たずに並列で処理を実行します。
以下の処理手順も画素単位で並列して行われているとお考えください。

#### 手順1 : メッシュの確認

まずGPUはジオメトリの頂点座標を計算し、担当する画素にメッシュが存在するかを確認します。今回のデモの場合、画面中央付近の画素では`outer`と`inner`メッシュがヒットします。

#### 手順2 : メッシュの深度の確認

次に、メッシュの情報から大まかな並べ替えを行います。デモページでは以下の順番でメッシュをsceneに追加しています。

```js
scene.add(this.outer);
scene.add(this.inner);
```
_(この登録順は問題の起きやすいものをあえて選んでいます。)_

innerメッシュのposition.xに応じて以下のような処理が行われます。

-   outerとinnerメッシュの両方がposition (0,0,0)の場合 : メッシュの情報からは深度の判定ができない。そのためsceneに登録した順番で処理をする。
-   innerメッシュのposition.xが0以外 : カメラの位置によってouterとinnerのどちらかが手前であることがわかる。カメラに向かって奧のオブジェクトから描画を行う。

#### 手順3 : 奥のメッシュの描画

まずカメラに向かって奥のメッシュを画素に描画します。画素の描画完了後、DepthBufferという領域にどのくらいの距離のメッシュを描画したかを記録します。

-   outerとinnerメッシュの両方がposition (0,0,0)の場合 : 登録順でouterから描画を行う。outerの形状でDepthBufferも書き込む。
-   innerが奥の場合 : innerの描画を行う。innerの形状でDepthBufferも書き込む。
-   outerが奥の場合 : outerの描画を行う。outerの形状でDepthBufferも書き込む。

DepthBufferにはカメラからの距離が格納されます。この距離は0.0 ~ 1.0の間に丸め込まれます。こちらの記事でDepthBufferの可視化処理を行なっています。ご参照ください。

[Depth buffer visualization](https://sites.google.com/site/cgwith3js/home/depth-buffer-visualization)

#### 手順4 : 手前のメッシュの描画とスキップ

次にカメラから向かって手前のメッシュを描画します。描画の前にDepthBuffer同士の比較を行います。
もしその画素に、現在のDepthBufferよりも手前の要素が描画済みなら、描画処理はスキップされます。

-   outerとinnerメッシュの両方がposition (0,0,0)の場合 : 描画済みのouterのDepthBufferは、innerのものよりも必ず手前になる。innerの描画はスキップされる。
-   innerが奥の場合 : 手前のouterの描画を行う。色がブレンドされる。新たにouterの距離がDepthBufferに上書きされる。
-   outerが奥の場合 : innerの描画がスキップされる。

一連の描画の手順は、こちらの記事でより詳しく解説されています。併せてご参照ください。

[WebGL Lesson 8 - 深度バッファと透過と色のブレンド](https://sites.google.com/site/hackthewebgl/learning-webglhon-yaku/the-lessons/lesson-8)

### 解決策

以上の手順でdepthTestが行われ、innerメッシュの描画をスキップするのが問題2の原因です。depthTestはすべてのオブジェクトが不透明の場合、高速で矛盾のない描画をする優れた手法です。しかし透過オブジェクトを重ねて描画した場合、奥のメッシュ描画がスキップされます。

これらの手順のどこかに割り込むことで、透過メッシュの内側問題を解消できないか検討します。

#### 解決策1 : depthTestとdepthWrite

##### depthTest

three.jsのマテリアルには、上記のdepthTestの処理を停止するプロパティがあります。

[Material.depthTest](https://threejs.org/docs/#api/en/materials/Material.depthTest)

これを利用することで、手順3および4の処理を変更できます。

Material.depthTestをfalseにすると、DepthBufferの読み込み、書き込み、比較の3つの処理が停止します。手順3ではDepthBufferの書き込みが行われず、手順4ではDepthBufferの読み込みと比較が行われません。

結果としてすべてのメッシュが描画されます。これで透過メッシュの奥にあるメッシュが描画スキップされてしまうことはなくなります。

デモページでは、コントローラーの**depthTest**チェックボックスをOFFにすると、表示されているメッシュのdepthTestが一括でfalseになります。
![](https://www.evernote.com/l/AAmo49UV6IdDP6rHKuW3AOmhRsC0Jp2ebPoB/image.png)
![](https://www.evernote.com/l/AAnZw7-Xc0VHN4RoTRfYcp8DMm6JEAjrf2gB/image.png)
ただし、Material.depthTestをfalseにしてもメッシュ重なり順が正しいとは限りません。今回のデモページの場合、innerメッシュを手前に表示すると、outerメッシュとの重なりを無視してその上に描画してしまいます。そのため色のブレンドが正しくありません。

##### depthWrite

three.jsのマテリアルには、depthWriteというプロパティもあります。

[Material.depthWrite](https://threejs.org/docs/#api/en/materials/Material.depthWrite)

Material.depthWriteをfalseにすると、DepthBufferへの書き込み処理のみが停止します。透過メッシュにはこの設定をtrueに、不透過メッシュではfalseに設定すると、不透過メッシュの描画スキップは正常に働き、透過メッシュによる描画スキップだけが停止します。

今回のデモページの場合、すべてのメッシュが透過なので、depthWriteプロパティでも透過オブジェクト同士の重なり順がおかしくなる問題を解決することはできません。

参考記事 : 
[Three.js - depthWrite vs depthTest for transparent canvas texture map on THREE.Points](https://stackoverflow.com/questions/37647853/three-js-depthwrite-vs-depthtest-for-transparent-canvas-texture-map-on-three-p)

#### 解決策2 : シーンへの登録順の変更

outerとinnerメッシュの両方がposition (0,0,0)の場合、描画順はシーンへの登録順で左右されます。これを確認するため、コントローラーには**SwapSphere**チェックボックスがあります。

SwapSphereチェックボックスをONにするとメッシュの登録順が逆転します。
![](https://www.evernote.com/l/AAm-gYUyUuZLtJ6BbSFA2HZOYSLMiSAZUJ8B/image.png)
innerメッシュは表示されましたが、これで正しく描画されるのはposition (0,0,0)の場合だけです。x座標を変更すると、手前側に移動したinnerメッシュは表示されません。

#### 解決策3 : renderOrder

Object3Dには`renderOrder`というプロパティがあります。このプロパティのデフォルト値はゼロです。

[Object3D.renderOrder](https://threejs.org/docs/index.html#api/en/core/Object3D.renderOrder)

このプロパティをゼロ以外の値にすると、[手順2 : メッシュの深度の確認](#手順2--メッシュの深度の確認)の手前でrenderOrderによる描画順の並び替え行われます。メッシュ座標やカメラ座標よりもrenderOrderによる描画順が優先されます。

innerメッシュのrenderOrderを`1`に、outerメッシュのrenderOrderを`2`に設定することで、innerメッシュが奥にあることを明示します。
![](https://www.evernote.com/l/AAkjH-p6yvpJQoJObzFBuzzhDZ9pYy4_vwIB/image.png)
![](https://www.evernote.com/l/AAnxQEVJ03tLO5ZIhPB_MzXtRXSda1kzjnoB/image.png)
innerメッシュがouterメッシュに内包されているかぎり、この設定で正しく描画が行われます。depthTestとDepthBufferも働いているため、メッシュ同士が食い込みも正しく描画されます。

::: note warn
three.js r133以前でrenderOrderの扱いが変わったようです。renderOrderに負の値を入力すると期待する結果を受け取れません。ご注意ください。
:::

renderOrderの描画順が正しく動作するのは、innerメッシュがouterメッシュの内側にいる場合のみです。innerメッシュが外側に飛び出すと、今度はouterメッシュが欠けてしまいます。
![](https://www.evernote.com/l/AAk6r_LXb4xHBpSZh_PU62-YIvegbUGzDm4B/image.png)

-   innerメッシュの座標変更に併せて、動的にrenderOrderを更新する。
-   innerメッシュの座標をouterメッシュの内側に制限する

などの対処が必要になります。

##### ハマりポイント : Object3DのrenderOrderを設定しても、子オブジェクトには反映されない。

renderOrderプロパティはObject3Dで定義されていますが、Object3DにrenderOrderを設定しても子オブジェクトには重ね順が反映されません。

[Object3D.renderOrder](https://threejs.org/docs/index.html#api/en/core/Object3D.renderOrder)
> When this property is set for an instance of Group, all descendants objects will be sorted and rendered together. Sorting is from lowest to highest renderOrder.

一括で子孫オブジェクトのrenderOrderを変更したい場合、Object3Dを継承したGroupクラスを利用しなくてはいけません。
[Three.js Group](https://threejs.org/docs/index.html#api/en/objects/Group)
[WebGLRendererのGroup処理](https://github.com/mrdoob/three.js/blob/master/src/renderers/WebGLRenderer.js#L1260)を覗き込むと、`isGroup`でGroupクラスのインスタンスかを確認しているのがわかります。

GroupとObject3Dは、どちらも3Dオブジェクトのコンテナーとして利用できます。そのためうっかりObject3DをコンテナーにしてrenderOrderを設定、描画が反映されずに悩むことがります。そういう時はGroupとObject3Dのどちらを使っているかを疑ってみてください。

## まとめ

three.jsの透過オブジェクトに影響を与える設定と、設定がどのような影響を与えるかの確認ができました。three.jsではこの設定をしておけば万事解決！という決定版の設定がありません。その時その時でベストな設定が変わり、設定を取捨選択する必要があります。

透過設定で悩んだ場合、このデモページを弄って設定を確認する助けになれば幸いです。

以上、ありがとうございました。