[Three.js r165] WebGPURenderer.outputColorSpaceはMaterial.opacityに影響を与える

## 先に結論だけ

Three.js r165において
- `WebGPURenderer`では`outputColorSpace`はMaterialの透明度に対して影響を与えます
- `WebGLRenderer`では`outputColorSpace`を変更してもMaterialの透明度は影響されません

WebGPURendererでWebGLRendererと同じようにMaterialの透明度を扱いたい場合は、[Colorクラス](https://threejs.org/docs/#api/en/math/Color)の変換関数を利用しましょう。

## はじめに

この記事は、Three.js r165のWebGPURendererにおいて、`outputColorSpace`とMaterialの透明度の関係性について調査した結果をまとめたものです。

### 対象とする読者

- Three.jsを使用している方
- WebGPURendererを使用しているか、使用する予定がある

### 対象とする環境

- Three.js r165
- Chrome v126.0.6478.63

:::note warn
この記事は、Three.js r165での挙動について記載しています。将来のバージョンでは挙動が変わるかもしれないので、記事を読む前にお手元の環境のバージョンを確認してください。
:::

## この記事で問題とする症状

WebGPURendererで、Materialの`transparent`をtrue、`opacity`を0.2程度に設定すると、WebGLRendererよりも明るく表示される症状が発生しました。

この症状が発生する原因を調査し、解決策を見つけることがこの記事の目的です。

## 実験

この問題の原因を探るため、必要最低限のデモページを作成しました。

https://masatomakino.github.io/threejs-lab/demo/StudyColorSpaceAlpha.html

マテリアルはライトやシェーダーの影響を受けない`MeshBasicMaterial`を使用しました。各レンダラーの色空間はデフォルトです。

### 実験結果

まず、RGBの値をすべて1に設定します。白を指定すると、RGB値はsRGB色空間とリニアsRGB色空間の影響を受けなくなります。

![StudyColorSpaceAlpha1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/85cddcc1-920c-c438-e443-ad0e0ab84a53.jpeg)

その上で、透明度を変更します。`opacity`を0.2程度に設定すると、WebGLRendererとWebGPURendererで異なる結果が得られます。

![StudyColorSpaceAlpha2.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/76a3b31b-c866-e96f-8bc5-e641cf2117db.jpeg)

最後に、`outputColorSpace`プロパティを変更します。リニアsRGBに設定すると、WebGLRendererの結果は変わりませんが、WebGPURendererの結果が変わります。

![StudyColorSpaceAlpha3.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9556/1a88bda7-6301-f04f-7ee3-d414c5215fb0.jpeg)


### Three.jsにおけるカラーマネージメントの概要

デモページの実験結果を理解するために、Three.jsにおけるカラーマネージメントの仕組みを読み解いていきます。

1. __ソース色空間__ : テクスチャやRGB入力値などの色空間で、ソースごとに異なる
2. __作業色空間__ : `ColorManagement.workingColorSpace`に設定された色空間で、Three.jsのシェーダー内部で利用され、色情報の四則演算やライト計算などはこの色空間で行われる
3. __出力色空間__ : レンダラーがCanvasに描画する際の色空間で、`outputColorSpace`プロパティで指定する

Three.jsはこの3つの色空間を相互変換しながら、レンダリングを行います。

それぞれの色空間のデフォルト値は以下の通りです。

1. __ソース色空間__ : ソースによって異なる
2. __作業色空間__ : sRGB-linear
3. __出力色空間__ : sRGB

### 色変換を追う

それでは、色空間の変換を処理ごとに追っていきます。

Colorクラスの色指定関数には、カラースペースを引数として指定するものがあります。たとえば、[setRGB()](https://threejs.org/docs/#api/en/math/Color.setRGB)関数は、第四引数にソース色空間を指定できます。

setRGB関数の色空間引数は、ドキュメント上では固定の値として記載されていますが、[ソースコード上](https://github.com/mrdoob/three.js/blob/134ff886792734a75c0a9b30aa816d19270f8526/src/math/Color.js#L114)ではColorManagementクラスの`workingColorSpace`プロパティを参照しています。

Colorクラスの色指定関数は、内部的にColorManagementクラスの[toWorkingColorSpace()](https://github.com/mrdoob/three.js/blob/134ff886792734a75c0a9b30aa816d19270f8526/src/math/ColorManagement.js#L108)関数を呼び出しています。この関数はソース色空間を作業色空間へ変換します。

```javascript
return this.convert(color, sourceColorSpace, this._workingColorSpace);
```

setRGB()関数に色空間を指定しない場合、ソース色空間と作業色空間が同じなので変換は実行されません。

[setHex](https://threejs.org/docs/?q=color#api/en/math/Color.setHex)関数や[setStyle](https://threejs.org/docs/?q=color#api/en/math/Color.setStyle)関数はデフォルトのソース色空間がsRGBになっています。これは[CSSとの互換性](https://developer.chrome.com/docs/css-ui/high-definition-css-color-guide?hl=ja#a_review_of_the_classic_color_spaces_classic-color-spaces)を考慮したためです。

### Rendererによる色空間の変換

`ColorManagement.workingColorSpace`プロパティは、レンダラー内部で使用される作業色空間を示しています。レンダラーはこの値で色の計算を行い、最後にレンダラーの[outputColorSpace](https://threejs.org/docs/#api/en/renderers/WebGLRenderer.outputColorSpace)に変換してCanvasに描画します。

WebGPURendererでは、`outputColorSpace`プロパティの変更でopacityのレンダリング結果が変わります。これは、作業色空間から出力色空間への変換が、透明度にも影響を与えていることを示します。

一方WebGLRendererでは、`outputColorSpace`プロパティの変更でopacityのレンダリング結果が変わりません。opacityは色空間変換をバイバスします。

### opacityを色変換する

WebGPURendererでWebGLRendererと同じようにMaterialの透明度を扱いたい場合は、Colorクラスの変換関数を利用しましょう。

```typescript
const color = new Color();
color.setRGB(opacity, 0, 0, "srgb");
material.opacity = color.r;
```

Colorクラスを介して透明度を作業色空間に変換します。レンダラーはopacityを出力色空間に変換するため、WebGLRendererと同じ結果が得られます。

## 個人的な感想

透明度は色ではなく、本来は色空間に影響されないものです。そのため、opacityも色空間の変換に影響されないと思い込んでいました。WebGPURendererでは、Blenderなどの3Dツールとの連携も考慮して、このような変換が行われているのかもしれません。最小限のテストで挙動を確認することが大切です。