環境
Unity2021.3.4f1
Renderer.material
のgetプロパティはマテリアルを複製する
のでsharedMaterial
を使いましょう、というのはよく聞く話です。
正確な理解のために、その複製の挙動を調査してみましょう。
確認用シーンとコード
同じマテリアルをアタッチしたオブジェクトを並べます。(3つは要らんかった……)
適当にmaterial
やsharedMaterial
にアクセスし、GetInstanceID
でインスタンスの同一性を確認していきます。
using UnityEngine;
public class MaterialTest : MonoBehaviour
{
[SerializeField] private Renderer _sphere0;
[SerializeField] private Renderer _sphere1;
[SerializeField] private Renderer _sphere2;
private void Start()
{
// sharedMaterialのインスタンス確認(同じはず)
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
Debug.Log($"(1, sharedMaterial) = {_sphere1.sharedMaterial.GetInstanceID()}");
// materialのGetterにアクセスしてみる(複製されて別インスタンスになってるはず)
Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
// もう一度sharedMaterialのインスタンス確認(0の方が変わってるはず)
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
Debug.Log($"(1, sharedMaterial) = {_sphere1.sharedMaterial.GetInstanceID()}");
// もう1度materialのGetterにアクセスしてみる(複製される?されない?)
Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
}
}
結果
1回目のmaterial
のgetプロパティアクセスで確かにマテリアルは複製されました。その後sharedMaterial
にアクセスしてもmaterial
にアクセスしても同じく複製後のマテリアルインスタンスが取得できました。2回目のmaterial
のgetプロパティアクセスでは複製は起きていないようですね。
複製される条件を探る
この結果から「2回目は複製されない」と言ってしまうのは少し安直な気がします。他の理由で複製されなかったのかもしれませんし、実は1回目じゃなくても複製されることがあるかもしれません。
1. 既に複製されたマテリアルが割り当てられている場合
最初から複製されたマテリアルをアタッチしていたらどうなるでしょう。
例えば以下の感じで事前にマテリアルを複製しておきます。(これはUnityに怒られますが、ここの手段はどうでもいいです)
[ContextMenu("Test/マテリアルチェンジ!")]
public void Test()
{
_ = _sphere0.material;
}
(Instance)
が付いてるので確かにマテリアルが複製されています。(まだエディタは再生していません。)
ではここから先程の確認コードを実行します。
結果
1回目のmaterial
のgetプロパティで複製され、2回目では複製されていないようです。getプロパティで複製が行われるかの判定には、今割り当てられているマテリアルが複製されたものかどうか(アセットかどうか)は関係無さそうです。
2. マテリアルをsetして、その後getする場合
setプロパティを使ってマテリアルを割り当てた後のgetプロパティはどのように動くでしょうか。
なお、material
とsharedMaterial
のsetプロパティは全く同じメソッド呼び出しを行なっているので、どちらを使っても変わりません。以下はRenderer
クラスの抜粋です。
public Material material
{
get
{
if (IsPersistent())
{
Debug.LogError("Not allowed to access Renderer.material on prefab object. Use Renderer.sharedMaterial instead", this);
return null;
}
return GetMaterial();
}
set { SetMaterial(value); }
}
public Material sharedMaterial { get { return GetSharedMaterial(); } set { SetMaterial(value); } }
名前が違うのに同じ挙動というのはとても気持ち悪いですが、事実どちらを使っても同じなのでとりあえずsetプロパティはmaterial
の方を使って検証することにします。
では1回setしてからmaterial
のgetプロパティにアクセスしてみます。
private void Start()
{
// 同じMaterialインスタンスを指してる確認
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
Debug.Log($"(1, sharedMaterial) = {_sphere1.sharedMaterial.GetInstanceID()}");
// materialのsetプロパティにアクセスしてみる
var newMat = new Material(_sphere0.sharedMaterial);
_sphere0.material = newMat;
Debug.Log(newMat.GetInstanceID());
// materialのgetプロパティにアクセスしてみる
Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
// sharedMaterialを確認
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
Debug.Log($"(1, sharedMaterial) = {_sphere1.sharedMaterial.GetInstanceID()}");
// 改めてmaterialとsharedMaterialの値を確認
Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
}
結果
setプロパティで割り当てたインスタンスnewMat
と、その直後にgetプロパティで取得したインスタンスは別物、つまりここで複製が起きているようです。
3. 1回getして、その後setして、さらにその後getした場合
もうやってることがめちゃくちゃですが、とりあえず確認しておきます。
private void Start()
{
// 同じMaterialインスタンスを指してる確認
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
Debug.Log($"(1, sharedMaterial) = {_sphere1.sharedMaterial.GetInstanceID()}");
// materialのGetterにアクセスしてみる(ここで複製されるはず)
Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
// materialのSetterにアクセスしてみる
var newMat = new Material(_sphere0.sharedMaterial);
_sphere0.material = newMat;
Debug.Log(newMat.GetInstanceID());
// materialのGetterにアクセスしてみる(ここがどうなるか)
Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
// 違うMaterialインスタンスを指してる
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
Debug.Log($"(1, sharedMaterial) = {_sphere1.sharedMaterial.GetInstanceID()}");
// 連続したmaterialのGetterアクセスは直前と同じものが返ってくるはず
Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
}
結果
最初のmaterial
のgetで複製が起こるのは当然ですが、さらに1度setを挟むと次のgetでも複製が起きているようです。
Renderer.material
の設計思想(個人の解釈です)
Renderer.material
プロパティは「このRenderer
インスタンスだけ見た目を変えたい!」という要求に応えるものとして設計されています。最初からアタッチされていたマテリアルや外部からセットされたマテリアルは他のRenderer
にもアタッチされているかもしれませんから、複製して「自分だけのマテリアルインスタンス」を作ることで対応する、という感じです。でももちろん
_sphere2.material = _sphere0.material;
みたいにすれば複製されたマテリアルを他Renderer
インスタンスに渡すことは可能です。
Renderer.material
はgetもsetも使わない
検証でなんとなく動きや設計思想が見えた感じはしますが、もしかしたらまだ理解が不十分かもしれません(UnityがC++部分を公開してくれればいいんですが……)。それに、インスタンスの状態によって動きが変わるのにそれが外からわからないAPIは単純に怖いです。複製されたマテリアルはこっちで破棄しなきゃいけないですから無視もできません。
ということで、materialプロパティは使わないでいいと思います。マテリアルインスタンスの差し替えを行いたければ素直にnew Material
してsetしましょう。
material
とsharedMaterial
のsetは中身が同じですが、いちいち「これはsetだからmaterial
を使っても大丈夫」とか考えたくないので、setもsharedMaterial
を使いましょう。
補足: 配列版プロパティについて
Renderer
にはmaterials
とsharedMaterials
というマテリアル配列を返すプロパティもありますが、こちらも、複製されうるマテリアルインスタンスの数が1つから複数になるだけで同様の議論が成り立ちます。materials
はやはり使わないで良いでしょう。
ちなみにmaterials
とsharedMaterials
のgetプロパティは配列を返すUnityAPIなので、例に漏れずアクセスのたびにGCAllocします。そして例に漏れず事前に確保した配列(今回はListですが)を引数に取るNonAlloc版のAPIも用意されているので、頻繁に呼び出す必要がある場合はNonAlloc版であるGetSharedMaterials
がお得です。