ノートの端の書き残し

UnityやらC#やら。設計が得意かもしれない。

Renderer.materialとsharedMaterial

Renderer.materialのGetterはマテリアルを複製する

なのでsharedMaterialを使いましょう、というのはよく聞く話です。
が、これだけだと実際何が起こってるのかわからんので具体的にインスタンスIDを見てみる方がわかりやすそうです。

確認

確認用シーンとコード

最初はどの球も同じマテリアルインスタンスを割り当てています。

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のインスタンス確認
        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()}");
    }
}

結果は以下のようになります。

最初のmaterialアクセスでは確かに複製が起きて、2回目は複製は起きていません。

最初から複製されたマテリアルをアタッチしていたらどうなる?

じゃあ最初からmaterialアクセスして複製されたマテリアルがアタッチされてる場合はどうなるんでしょうか。気になりますね。

[ContextMenu("Test/マテリアルチェンジ!")]
public void Test()
{
    _ = _sphere0.material;
}

これを実行するとマテリアルリークするぞって怒られますが、無視します。

↑複製されたマテリアルが割り当てられていますね。この状態で最初のコードを動かしてみます。

結果はこうなります。

複製の複製が生成されています。マテリアルインスタンスがアセットであるかどうかが判定基準というわけではないようです。

Setterにアクセスしたらどうなる?

Renderer.materialとRenderer.sharedMaterialはプロパティとしては違いますが、中身は全く同じです。

/// <summary>
///   <para>Returns the first instantiated Material assigned to the renderer.</para>
/// </summary>
public Material material
{
    get
    {
        if (!this.IsPersistent())
            return this.GetMaterial();
        Debug.LogError((object) "Not allowed to access Renderer.material on prefab object. Use Renderer.sharedMaterial instead", (Object) this);
        return (Material) null;
    }
    set => this.SetMaterial(value);
}

/// <summary>
///   <para>The shared material of this object.</para>
/// </summary>
public Material sharedMaterial
{
    get => this.GetSharedMaterial()
    set => this.SetMaterial(value);
}

これを踏まえて、materialのGetterだけじゃなくてSetterも使ってインスタンスの複製がどうなるか見てみましょう。
確認コードは最初のコードにSetterアクセスを混ぜて以下のようにします。
最初は全ての球に同じマテリアルインスタンスを割り当てています。

private void Start()
{
    // 同じMaterialインスタンスを指してる
    Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
    Debug.Log($"(1, sharedMaterial) = {_sphere1.sharedMaterial.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()}");
        
    // 2回目のmaterialのGetterアクセスは1回目と同じものが返ってくる
    Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
    Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
}

結果は以下のようになります。

Setterで割り当てたnewMatインスタンスと、その直後にmaterialのGetterで取得したインスタンスは別物のようですね。

1度materialのGetterにアクセスして、その後Setterにアクセスして、さらに Getterにアクセスする

もうめちゃくちゃですが、試してみましょう。

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()}");
        
    // 2回目のmaterialのGetterアクセスは1回目と同じものが返ってくる
    Debug.Log($"(0, material) = {_sphere0.material.GetInstanceID()}");
    Debug.Log($"(0, sharedMaterial) = {_sphere0.sharedMaterial.GetInstanceID()}");
}

結果は以下です。

なんと、1度Setterを挟むと、2回目のGetterアクセスでも複製が起きているようです。

要するに

Renderer.materialのGetterプロパティは初回アクセスの際にはマテリアルを複製し、セットしてから返すという挙動をする。 さらに、1度Setterアクセスを挟むと再び次は初回アクセス扱いになるようです。

とても面倒ですね。しかもランタイムで生成したマテリアルは明示的に破棄しないといけないので、割と実害があります。
色々調査を行いましたが、sharedMaterialでは複製は起きないので、結論としては変わらず、「sharedMaterialを使う」が正解だと思います。
materialのGetterで複製が生成されるから~とか横着しようと考えず、
マテリアルインスタンスを差し替えたいときは明示的にSetterを使い、Getterを使うときは必ずsharedMaterialを使う。
というルールが安全そうです。

さらに、SetterはmaterialもsharedMaterialも同じ処理を呼び出していました。
つまり、materialプロパティは不要です。 materialプロパティを使わない運用に問題があるとすれば「sharedMaterialのsharedってなんだよ」という気持ちになることくらいなので、使わなくても一切困りません。

複数形プロパティに関して

Renderer.materialsとRenderer.sharedMaterialsのことです。こちらも同じで、materialsにアクセスした瞬間全てのマテリアルが複製されます。sharedMaterialsのSetterからマテリアルの変更は行うようにし、materialsプロパティはGetterもSetterも使わない、で良いと思います。面倒なので。

ちなみに、sharedMaterialsは毎回配列生成を伴うので、頻繁に呼び出すならRanderer.GetSharedMaterialsがおすすめです。

docs.unity3d.com