Blender→Unity間で32bitテクスチャ(EXR)で数値の受け渡しを行う

## はじめに
BlenderにてEXRテクスチャを出力してその情報をUnityで使うということがやりたかったのですが、案外情報が無くて困ったのでまとめていきます。

# 宣伝
Blenderのアニメーションをテクスチャに書きこんで、Unityにて再生させるVATを開発しました。
よろしくお願いします。

[【無料】nekoVATシステム](https://nekoco.booth.pm/items/5943052)

# Blender
Blenderのバージョンは4.1.1 Stableを使っていきます。

### テクスチャ作成
スクリプトを使って通常テクスチャを作成していきます。
```python
import bpy
import numpy as np

texture_name = 'CustomTexture'
resolution = 4

# 画像を取得または作成
image = bpy.data.images.get(texture_name)
if not image:
    image = bpy.data.images.new(texture_name, width=resolution, height=resolution, alpha=True)
elif image.size[0] != resolution or image.size[1] != resolution:
    image.scale(resolution, resolution)

point = np.array(image.pixels[:])
point.resize(resolution, resolution * 4)
point[:] = 0

point_R = point[::, 0::4]
point_G = point[::, 1::4]
point_B = point[::, 2::4]
point_A = point[::, 3::4]

# 色を指定 [y][x]
point_R[0][0] = 1
point_A[0][0] = 1
point_G[3][0] = 1
point_A[3][0] = 1

# テクスチャに書きこむ
image.pixels = point.flatten()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/576ba016-4f95-beb8-9e0a-b4b4ff01a1bc.png)

基本的なテクスチャ作成スクリプトです。

参考ページ
[Blender Python API - Image(ID)](https://docs.blender.org/api/current/bpy.types.Image.html)
[Blenderではじめる画像処理](https://qiita.com/nacasora/items/cf0e27d38b09654cf701#その他細かい部分についてのtips)

特筆すべき点を以下にまとめます。

#### テクスチャの作成
既に同名のテクスチャが存在する場合にimages.newを実行すると〇〇.001という名前のテクスチャが作成されます。
以降も同様に番号が増えてテクスチャが増えてきます。
そのためそれを回避しています。

またテクスチャサイズが異なる場合は変更します。

#### numpyの使用
今回は色の指定にnumpyを使用しています。

別途変数を作らなくても、image.pixelsに直接書きこむこともできます。
しかしその場合、色を指定するごとにテクスチャを更新するためか実行時間がかなり増えてしまいます。

そのため、numpyを用いて最後に書きこむこの形を採用します。

### EXRテクスチャの作成
```diff_python
import bpy
import numpy as np

texture_name = 'EXR_Texture'
resolution = 4

# 画像を取得または作成
image = bpy.data.images.get(texture_name)
if not image:
-     image = bpy.data.images.new(texture_name, width=resolution, height=resolution)
+     image = bpy.data.images.new(texture_name, width=resolution, height=resolution, alpha=True, float_buffer=True)
elif image.size[0] != resolution or image.size[1] != resolution:
    image.scale(resolution, resolution)

+ image.file_format = 'OPEN_EXR'

point = np.array(image.pixels[:])
point.resize(resolution, resolution * 4)
point[:] = 0

point_R = point[::, 0::4]
point_G = point[::, 1::4]
point_B = point[::, 2::4]
point_A = point[::, 3::4]

# 色を指定 [y][x]
point_R[0][0] = 100
point_A[0][0] = 100
point_G[3][0] = 0.123456789
point_A[3][0] = 0.123456789

# テクスチャに書きこむ
image.pixels = point.flatten()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/8336b9d3-3779-aa76-0dfc-f0d7b2119cc8.png)

画像作成時の引数を追加しました。

色情報をfloat値として保存したいので、float_buffer=True
[Blender Python API - BlendDataImages(bpy_struct)](https://docs.blender.org/api/current/bpy.types.BlendDataImages.html#bpy.types.BlendDataImages.new)

また、ファイルフォーマットの指定をしました。

### クラス化
今回の内容的に必須ではありませんが、やっておくと便利なので書いておきます。
```python
import bpy
import numpy as np

class TextureClass:
    def __init__(self, texture_name, width, height):
        self.image = bpy.data.images.get(texture_name)
        if not self.image:
            self.image = bpy.data.images.new(texture_name, width=width, height=height, alpha=True, float_buffer=True)
        elif self.image.size[0] != width or self.image.size[1] != height:
            self.image.scale(width, height)
        
        self.point = np.array(self.image.pixels[:])
        self.point.resize(height, width * 4)
        self.point[:] = 0

        self.point_R = self.point[::, 0::4]
        self.point_G = self.point[::, 1::4]
        self.point_B = self.point[::, 2::4]
        self.point_A = self.point[::, 3::4]
    
    def SetPixel(self, py, px, r, g, b, a):
        self.point_R[py][px] = r
        self.point_G[py][px] = g
        self.point_B[py][px] = b
        self.point_A[py][px] = a

    def Export(self):
        self.image.colorspace_settings.name = 'Non-Color'
        self.image.pixels = self.point.flatten()
        self.image.file_format = 'OPEN_EXR'


image = TextureClass('EXR_Texture', 4)

image.SetPixel(0, 0, 100, 0, 0, 100)
image.SetPixel(3, 0, 0, 0.123456789, 0, 0.123456789)

image.Export()
```


### テクスチャ保存方法
今回は手動でテクスチャを保存します。
画像エディターなどから作成したテクスチャを保存してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/cbac4ffb-ffe1-1bdd-8ed2-6ce5e5882ca5.png)

保存形式は以下の通りです。

| 項目 | 値 |
|:-:|:-:|
| ファイルフォーマット | OpenEXR |
| カラー | RGBA |
| 色深度 | Float(Full) |
| コーデック | なし |
| 色空間 | 非カラー |

色空間は設定値が沢山ありますが、おそらく「非カラー」で保存すれば無変換で数値を保存できると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/c78510f2-32af-bf7a-8bea-a108df4316f3.png)


# Unity
知っておくべき前提知識があります。
それが色空間です。

### 色空間(Color Space)
まず、検証用のテクスチャを用意します。
BlenderにてR：100、G：1、B：-1、A：100のテクスチャを用意しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/5081dd46-70e4-b0f7-7f29-920d86dbf11f.png)

次にこちらのテクスチャをUnityにて確認します。

テクスチャのインポート設定は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/002a0dbf-a264-1fa4-4b9c-78f14cb9e503.png)
32bitテクスチャなので、FormatをRGBA Floatにしました。

次に、このテクスチャのRGBA値を表示します。

今回数値表示に使用したのはブタジエン氏のシェーダーを使用しました。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">シェーダ内の任意の数値を任意の桁数を指定して表示するシェーダ（というか関数とテクスチャの組み合わせ）できた～ <a href="https://t.co/IvxuqsFs4Z">pic.twitter.com/IvxuqsFs4Z</a></p>&mdash; ブタジエン (@butadiene121) <a href="https://twitter.com/butadiene121/status/1056257382680940545?ref_src=twsrc%5Etfw">October 27, 2018</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

#### 表示結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/a1238987-99b9-e8e8-7eb2-ae2f2babad3b.png)

小数4桁あたりの値は無視するにしても、Rが本来とかけ離れた値になっています。

この違いは色空間が異なるため起こっています。

色空間については以下の資料が良いでしょう。
[物理ベースレンダリング -リニアワークフロー編 (1)-](https://tech.cygames.co.jp/archives/2296/)
[物理ベースレンダリング -リニアワークフロー編 (2)-](https://tech.cygames.co.jp/archives/2339/)

色空間をガンマからリニアに変換する場合、参考記事によるとおおよそ2.2乗することで近い値となるようです。

```math
8.1113^{2.2} = 99.99977…
```

100に近い値となりました。

つまり、テクスチャをリニアとして保存したがUnityがガンマだと認識しているようです。

テクスチャのImport Activityを見てみるとGammaとなっていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/4e8cbca0-67b6-c628-d09d-40bf76759d11.png)

これをLinerに変更したいわけですが、テクスチャのインポート設定にはそれらしい項目が見当たりません。

png画像の場合はsRGBという項目が現れそれを変更することで設定できるのですが、どうやらexrでは消えてしまうようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/80f318cc-8390-1d7b-a355-c35400f38810.png)

ではexrはどこの設定値を参照しているかというと、プロジェクトの色空間を参照しているようです。

プロジェクト設定は[Edit]→[Project Settings]で表示できます。
その中の、[Player]→[Other Settings]→[Rendering]→[Color Space]で変更できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/8efb874b-14df-75e9-4377-5fdb456ff15f.png)

Linerに変更した結果、正常に数値を読み取ることが出来ました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/88b12bd6-9016-292c-510e-ba283def0aa0.png)

シェーダーでfloat値を読み出す場合は、代入する変数をfloat4にすればいいようです。

```hlsl
float4 tex = tex2D(_MainTex, i.uv);
```


これにて32bitテクスチャにてBlender→Unity間の数値の受け渡しを行うことが出来ました。

# その他検証情報
### Blender：非カラーで保存しても色味が異なる？
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">①色空間：非カラー<br>②Blenderでテクスチャを再読み込み、色味はおかしいがRGBの値は同じ<br>③Unityインポート時の設定<br>④Unity内での色味と値 <a href="https://t.co/Di61yymkMz">pic.twitter.com/Di61yymkMz</a></p>&mdash; nekoco (@nekoco_vrc) <a href="https://twitter.com/nekoco_vrc/status/1813907750067597601?ref_src=twsrc%5Etfw">July 18, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

色味は異なりますが、RGBA値は正常に取得できているようです。

### Blender：色空間を指定できない？
```python
import bpy
import numpy as np

# テクスチャ名を変数で設定
texture_name = 'EXR_Texture'
# 解像度を指定
resolution = 4
# 画像を取得または作成
image = bpy.data.images.get(texture_name)
if not image:
    image = bpy.data.images.new(texture_name, width=resolution, height=resolution, alpha=True, float_buffer=True)
elif image.size[0] != resolution or image.size[1] != resolution:
    image.scale(resolution, resolution)

point = np.array(image.pixels[:])
point.resize(resolution, resolution * 4)
point[:] = 0

point_R = point[::, 0::4]
point_G = point[::, 1::4]
point_B = point[::, 2::4]
point_A = point[::, 3::4]

# 色を指定
# [y][x]
point_R[0][0] = 100
point_A[0][0] = 100
point_G[3][0] = 0.123456789
point_A[3][0] = 0.123456789

# 書きこむ前に変更
image.colorspace_settings.name = 'Non-Color'
# テクスチャに書きこむ
image.pixels = point.flatten()
# 書きこんだ後に変更
image.file_format = 'OPEN_EXR'
```

指定できないことは無いのですが、上記のような順番でないと正常に認識しませんでした。

- 書きこんだ後に色空間を指定すると、色情報が無くなる
- 書きこむ前にフォーマットを指定すると、適応されない（PNGになる）

なおこの記述順の場合、テクスチャのリサイズが動かないようです。
サイズを変更していない場合は正常に動作するのですが、変更すると以下のようなエラーが発生します。

:::note alert
Python: Traceback (most recent call last):
  File "C:\Users\neko\3D Objects\Blender\4.1.1\script_test_1.blend\SC_Texture_2.py", line 36, in <module>
ValueError: bpy_struct: item.attr = val: Image.pixels: array length cannot be changed to 100 (expected 64)
:::

### Blender：EXRでファイル保存できない？
ファイルとして保存してくれるスクリプトを作成しました。

```python
import bpy
import numpy as np

# テクスチャ名を変数で設定
texture_name = 'EXR_Texture'
# 解像度を指定
resolution = 4
# 画像を取得または作成
image = bpy.data.images.get(texture_name)
if not image:
    image = bpy.data.images.new(texture_name, width=resolution, height=resolution, alpha=True, float_buffer=True)
elif image.size[0] != resolution or image.size[1] != resolution:
    image.scale(resolution, resolution)

image.file_format = 'OPEN_EXR'

point = np.array(image.pixels[:])
point.resize(resolution, resolution * 4)

# 初期化
point[:] = 0

point_R = point[::, 0::4]
point_G = point[::, 1::4]
point_B = point[::, 2::4]
point_A = point[::, 3::4]

# 色を指定
# [y][x]
point_R[0][0] = 100
point_A[0][0] = 100
point_G[3][0] = 0.123456789
point_A[3][0] = 0.123456789

# テクスチャに書きこむ
image.pixels = point.flatten()

# 出力パスの設定
output_path = "C:/Users/neko/3D Objects/Blender/4.1.1/Texture/EXR_Texture.exr"
image.save_render(filepath=output_path, quality=100)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299484/4966bde1-c19c-33dc-1663-86aaee88fa6f.png)

上記スクリプトで保存した画像を読み込んでみたのですが、RGBAの上限値が1.0になっています。
ファイルフォーマットがRGBA byte型となっており、色空間もSRGB_ABとして認識しているようです。

### Unity：色空間が異なってもビルドイン関数で補完する？
シェーダー内で使えるUnityCG.cgincには色空間を変換する関数が実装されているそうです。

[GammaToLinearSpaceExact - Unity-Built-in-Shaders/CGIncludes
/UnityCG.cginc](https://github.com/TwoTailsGames/Unity-Built-in-Shaders/blob/6a63f93bc1f20ce6cd47f981c7494e8328915621/CGIncludes/UnityCG.cginc#L95)

また色空間の判定はマクロで判定できるそうです。

```hlsl
#ifdef UNITY_COLORSPACE_GAMMA
    //...
#else
    //...
#endif
```

ではこれらを使えば色空間がガンマでも実装できるのでしょうか？

答えは、完全に復元することは不可能です。

まず、検証結果を見るとマイナス値がそもそも取れていないようです。
これでは変換のしようがありません。

続いてマイナス値以外ですが、こちらも完全には復元できないでしょう。

ビルドイン関数を見てみると、数値をべき乗して変換していることがわかります。
floatの仮数部は23bitですが、その数値をべき乗すると23bitに収まらないことは想像に難くありません。

つまり近似値でしかないようです。

そのためbit値を用いた数値圧縮などは用いることが出来ません。
VATでは16bit変数を32bitに2つ格納するということをやっていたのですが、それが出来ませんでした。