記事の背景(発生した問題)と目的
最近、メタバース構築のための技術検証として、ブラウザで、バーチャル空間で音声通信や画面共有をして、会議できるデモを開発しています。下記はデモのスクショです。
画面共有を実装する時に、一つ問題が起きました。ユーザーAがユーザーBに画面を共有する時に、Bが受信した画面の色は常にAが送信した画面より薄かったです。
下記のパワポ画面の例がわかりやすいかと思います。左はAが送信した元のパワポ画面です。右はBが受信した画面です。Aの画面にある青色、赤色とパワポのタイトルバーはBでは全部薄くなりました。
問題の原因を究明して、Unityの色空間についてかなり勉強になりました。勉強した知識をこの記事で共有したいと思います。
使用したツールのバージョン
3D空間をUnity 2021.3.18f1で、
画面共有をAgora Unity WebGL Plugin Refactor 7 Releaseで実装していました。
修正方法
Agora Unity WebGL Plugin内部のJavaScriptソースコードを弄って修正しました。この修正はプルリクエストとしてPluginの開発者にもマージされました。
Plugin内部のソースコードに、updateRemoteTexture
というJavaScript関数があります。名前の通り、リモートから受信した画面のテクスチャを更新する関数です。
updateRemoteTexture:
function (userId, tex) {
// ...
GLctx.texImage2D(
GLctx.TEXTURE_2D,
0,
GLctx.RGBA, // テクスチャがGPUに書き込まれた時のフォーマット
GLctx.RGBA,
GLctx.UNSIGNED_BYTE,
v
);
この関数はGLctx.texImage2D
というWebGLのAPIを呼び出します。
関数の三番目のパラメータですが、修正前はGLctx.RGBA
です。これはテクスチャがGPUに書き込まれた時のフォーマットです。これが色の問題が発生した根本的な原因です。
updateRemoteTexture
関数を下記に修正しました。
// isLinearColor: 色空間はリニアかどうか
updateRemoteTexture:
function (userId, tex, isLinearColor) {
// ...
GLctx.texImage2D(
GLctx.TEXTURE_2D,
0,
isLinearColor ? GLctx.SRGB8_ALPHA8 : GLctx.RGBA,
GLctx.RGBA,
GLctx.UNSIGNED_BYTE,
v
);
修正として、updateRemoteTexture
関数に、isLinearColor
というパラメータを追加しました。
現在の色空間がリニアであれば、このパラメータはtrue
になります。そして、texImage2D
の三番目のパラメータに、RGBAではなく、sRGBを設定します。これで問題を解決しました。
ガンマ色空間、リニア色空間とsRGBとは?
さっき修正方法を説明した時に、色空間、リニアの色空間とsRGBという専門用語をあえてそのまま書いてしまいました。問題原因と修正方法をしっかり理解するために、まずはこれらの専門用語を理解しないといけないです。ちなみに、リニアの色空間があれば、ガンマの色空間もあります。
色空間:色と数値の関係性(色のコーディング)
色空間は簡単に言えば、色空間とは色と数値の関係性です。
多分一番よく使われるのはRGBという色空間です。(1.0, 0.0, 0,0)は赤、(0.0, 1.0, 0.0)は緑、(0.0, 0.0, 1.0)は青。RGBの他に、HSL、YUV、XYZやCMY、色々な色空間があります。
色を数値で表す時に、使っている色空間によって、同じ色に対応する数値はそれぞれになります。
つまり、色空間は言い換えれば、色のコーディングとも言えます。
RGB色空間に、RGBの数値が大きいほど、色は見た目的に薄くて、白に近いです。
例えば(0.0, 0.0, 0.0)の真っ黒の数値を少しづつ大きくしたら、黒はだんだんグレーになって、最終的に白になります。(1.0, 0.0, 0.0)の赤も、数値を大きくしたら、だんだんピンクになって、最終的にも白になります。
画面共有を実装する時に出てきた色問題を振り返ってみると、受信側の色が薄いというのは受信側のRGB値が送信側より大きいです。例えば送信側の青色のRGB値は(0.07, 0.13, 0.97)ですが、受信側では(0.12, 0.38, 0.98)に変わりました。
なぜRGB値が大きくなったか、また後で解説します。
実はRGB値が大きいほど、もう一つの意味として、その色を再現するために必要な物理的なエネルギーが多いです。その物理的なエネルギーは光や電圧などがあげられます。例えば白を再現するために、モニターは電圧をいっぱいかけないといけない一方、黒を再現するならあまり電圧をかける必要がないです。
RGB値0.5のグレーに必要なエネルギー量は50%ではない
厳密ではないですが、仮にRGB値0の色に必要なエネルギー量は0.0で、RGB値1の色に必要なエネルギー量は1.0としたら、0.5のRGB値を再現するために、必要なエネルギー量は実は0.5ではなく、一般的に0.22です。
これはなぜかとえば、人間の目が感じる色は物理的なエネルギー量に素直に比例しないのです。
二つの関係は $y = x^\gamma$ で、指数関数で表せます。下記のような曲線になります。
(図はLIGHT11の記事からお借りしています。)
横軸の$x$はエネルギー量で、縦軸の$y$は人間の目が感じる色、もしくはRGB値です。
この曲線から見れば、エネルギー量が低い時に、少しだけ変わったら、色は大きく変わります。
逆にエネルギー量が高い時に、大きく変わっても、色はそんなに大きく変わらないです。
RGB値が0から0.5になるまで、必要なエネルギー量の変化はそんなに大きくないです。この曲線からすれば、大体0.22で十分です。
(下記の図もLIGHT11の記事からお借りしています。)
即ち、私たちがよくRGBフォーマットで保存した画像をモニターで正しく表示するには、表示の前に必ず何らかの変換が入ります。この変換はリニア変換と言います。一般的にはGPUかモニターが自動的にやってくれて、私たちに気付かれないです。
ただ、Unityでは一つ例外があります:リニア色空間で非sRGBテクスチャを表示する時に、この変換はないです。実はこの例外が記事の冒頭に書いた色問題の根本的な原因です。
Unityの色空間とsRGB設定
Unityのプロジェクト設定に、色空間という設定オプションがあって、ガンマとリニア二つの選択肢があります。デフォルトはガンマです。ガンマを選択した場合、色のRGB値を一切人間の知覚に基づいた数値として読み込んで、モニターに表示する前にリニア変換を行います。つまり0.5のグレーはモニターに表示される前におよそ0.22に変換されます。
リニアの場合はどうでしょう?
実は一概とは言えないです。Unityプロジェクトでテクスチャファイルを選択した時に、インポート設定に、sRGBというオプションがあります。このチェックはテクスチャを人間の知覚に基づいたRGB色として読み込むかどうかを設定するオプションです。デフォルトは入れています。
色空間がガンマの場合、このチェックが入れているかどうかにも関わらず、テクスチャを表示する時に必ずリニア変換が行われます。一方、色空間がリニアの場合、もしこのチェックを外したら、テクスチャ表示の時に、リニア変換は行われないです。つまり、リニア変換が行われる条件として、色空間がガンマです、もしくは、テクスチャのsRGBチェックが入れています。
色問題の原因:リニア変換が行われなかった
ここから、冒頭に言った色問題の原因が解説できます。
一言で言えば、リニア変換が行われなかったのです。
私たちのプロジェクトはリニアの色空間を使って、受信した画面をsRGBとして読み込まなかったです。
この場合、例えば0.5のピクセルを変換せずに、直接モニターに表示したらどうなりますか。つまり、この0.5は直接エネルギー量として扱われます。人間の目が感じる色と物理的なエルギー量の関係を表す曲線からすると、もしエネルギー量が0.5であれば、対応の色はおよそ0.73になります。0.5のグレーよりもっと明るいです。これが受信した画面の色が薄くなった原因です。
修正方法の考察
原因がわかったら、修正は簡単そうです。リニア変換を行えばいいですね。
おさらいになりますが、Unityプロジェクトの色空間がガンマの時に、もしくはテクスチャがsRGBの時に、リニア変換が行われます。
ということは、色空間をガンマにすれば、もしくはテクスチャをsRGBにすれば、問題は修正できそうです。
私たちは両方とも試しました。
色空間をガンマにしてみたら
色空間をガンマにしてみたら、確かに受信した共有画面の色は正しくなりました。この問題自体は修正できました。しかし、3D空間は明るすぎになりました。ライティングが強すぎになりました。下記の図に、右下は元々色空間がリニアの時の正しいライティングです。右上は色空間をガンマに変更した後のライティングです。
Unityのガンマ色空間とリニア色空間
ライティングが変わった原因をわかるには、Unityのガンマ色空間とリニア色空間の違いをわかる必要があります。
テクスチャやライティングの色情報がモニターに表示される前に、色情報に対するシェーダー演算が行われます。色のリニア変換はシェーダー演算の後にするか、前にするかがガンマ色空間とリニア色空間の大きな違いです。つまり、ガンマ色空間の場合、シェーダーが扱う値は人間の知覚に基づくRGB値です。リニア色空間の場合、シェーダーが扱う値はネルギー量です。
ガンマ色空間の問題
仮にシーンにディレクショナルライトが二つあります。色は二つとも0.5のグレーです。方向や強さなど他の設定も全く同じです。では、最終的なライティング、この二つのライトの足し算は何の色でしょうか?
リニア色空間は、物理に基づいて、足し算をやる前に、ライトの色をリニア変換でエネルギー量に変えます。シェーダー演算で扱う数値はRGB値の0.5ではなく、エネルギー量の0.22です。足し算の結果はエネルギー量0.44で、RGB値0.7です。つまり、二つ0.5グレーのライトを足しても1.0の白にならないです。
しかし、ガンマ色空間はライトの色を変換せずに、直接シェーダーで足します。結果としは1.0の白いライトになります。シェーダー演算をやる前に色に対してリニア変換を行わないので、ガンマ色空間ではライティングは大体リニア色空間より明るいです。
より正しいライティングを計算するために、一般的にリニア空間がおすすめです。物理ベースレンダリング(PBR)とハイダイナミックレンジレンダリング(HDR)を実現するには、リニア色空間はむしろ必須です。
テクスチャをsRGBにする?
では、テクスチャをsRGBにしてはどうでしょう。
結論からいうと、これは確かに正解です。
ただ、簡単なことではないです。
前にお見せしたテクスチャのインポート設定画面に、確かにsRGBのチェックがあります。このチェックを入れればいいんじゃない?と思われるかもしれません。問題は、テクスチャファイルを選択しないと、このインポート設定画面は出ないです。つまりこのsRGBチェックもファイルにしか出ないです。しかし、リアルタイムで受信した共有画面はファイルに保存されないです。
じゃどうすればいいですか?
Agora Unity WebGL Pluginで共有画面を受信する仕組み
これはAgora Unity WebGL SDKで共有画面を受信する仕組みを理解しないといけないです。
リモートから受信した画面データはまずAgoraのデータ構造に保存されます。そのデータはUnityのテクスチャに入れられて、モニターに表示されます。Unityのテクスチャはファイルではなくて、プログラムが実行する時にTexture2D
というUnityのC#クラスでメモリに生成されます。そして、AgoraのプラグインはtexImage2D
というWebGL JavaScript APIでテクスチャデータをUnityのTexture2D
のに渡します。
GLctx.texImage2D
メソッド
texImage2D
はCPUにあるテクスチャデータをGPUに送るWebGL APIです。公式の説明はこのリンクご参照ください。
APIのパラメータリストにはinternalformatというパラメータがあります。これはテクスチャデータを何のフォーマットとしてGPUに送るかという意味です。このパラメータを通してテクスチャをsRGBに設定することができます。
texImage2D(
target, // GLctx.TEXTURE_2Dを設定する
level, // 詳細度(Level of Detail, LOD)
internalformat, // 何のフォーマットとしてGPUに送るか
// GLctx.SRGB8_ALPHA8を設定したら、
// シェーダー演算の前にリニア変換が行われ、問題解決
format, // CPUにあるテクスチャデータのフォーマット
type, // 各ピクセルのビット構造
pixels // CPUにあるテクスチャデータ(受信した画面データ)
)
internalformat
をsRGBに設定したら、シェーダー演算の前にリニア変換が行われて、色が薄くなる問題が解決できました。
まとめ
- 色空間は色と数値の関係性、もしくは色のコーディング
- Unityのガンマ色空間は人間の視覚に基づいた色空間。中間グレーは~0.5
- Unityのリニア色空間は物理的なエネルギー量に基づいた色空間。中間グレーは〜0.22
- sRGBテクスチャがモニターに表示する前に、リニア変換(0.5 -> 0.22)を行う
- ガンマ色空間の場合、シェーダー演算の後に行う
- リニア色空間の場合、シェーダー演算の前に行う
-
GLctx.texImage2D
のWebGL APIはテクスチャをGPUに送る時に、internalformat
をsRGBに設定することで、リニア変換をシェーダー演算の前に行える
参考資料
- internalformat: specifying the color components in the texture .
- format: specifying the format of the texel data
- type: specifying the data type of the texel data
For the texImage calls, the format and type define the number of channels and data type of the incoming data (i.e. CPU format), the internalformat defines the format that GPU will use.(https://github.com/KhronosGroup/WebGL/issues/3472#issuecomment-1206694491)