MRが楽しい

MRやVRについて学習したことを書き残す

record型のwith式を使って一部のプロパティを変更した新しいオブジェクトを作成する

本日は C# の小ネタ枠です。
C# 9.0 以降で利用可能な with 式を使ってインスタンスから一部のプロパティを変更した新しいインスタンスを生成してみます。

with式

with 式は C# 9.0 以降で利用可能です。
C# 9.0 では対象のオペランドが record 型である必要があります。
C# 10.0 では対象が構造体型または匿名型でも利用できます。
learn.microsoft.com

Unity 2021.2 以降は C# 9.0 に対応しているため、このバージョンであれば Unity でも with 式が利用可能です。
unity.com

サンプルシーン

以下のサンプルスクリプトを作成して動作を確認してみました。
・WithTest.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WithTest : MonoBehaviour
{
    private record Monster
    {
        public string Name;
        public string Breeds;

        public Monster(string name, string breeds)
        {
            Name = name;
            Breeds = breeds;
        }
    }

    void Start()
    {
        var holomon = new Monster("HoloMon", "HolographicMonster");
        var bell = holomon with { Name = "Bell" };

        Debug.Log($"holomon = {holomon}");
        Debug.Log($"bell = {bell}");
    }
}

シーンを再生すると一部のプロパティが変更された新しいオブジェクトが生成されていることが分かります。

Unity2020環境

前述の通り、Unity 2020 環境は C# 8.0 のため、with 式はエラーが発生して利用できません。

Tips

Unity 2020 環境で同様のことをする場合、以下のようにスクリプトを作成します。
・WithTest.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WithTest : MonoBehaviour
{
    private class Monster
    {
        public string Name;
        public string Breeds;

        public Monster(string name, string breeds)
        {
            Name = name;
            Breeds = breeds;
        }

        public Monster WithName(string name)
        {
            return new Monster(name, this.Breeds);
        }

        public Monster WithBreeds(string breeds)
        {
            return new Monster(this.Name, breeds);
        }
    }

    void Start()
    {
        var holomon = new Monster("HoloMon", "HolographicMonster");
        var bell = holomon.WithName("Bell");

        Debug.Log($"holomon = {holomon.Name}, {holomon.Breeds}");
        Debug.Log($"bell = {bell.Name}, {bell.Breeds}");
    }
}

シーンを再生すると一部のプロパティが変更された新しいオブジェクトが生成されていることが分かります。

VContainerのHelloWorldを試す その5(EnqueueParentを使ってシーンを跨いで親LifetimeScopeを設定する)

本日は VContainer の小ネタ枠です。
EnqueueParent を使ってシーンを跨いで親 LifetimeScope を設定する手順について記事に残します。

前回記事

以下の前回記事のプロジェクトを利用します。
bluebirdofoz.hatenablog.com

シーンを追加する

異なる LifetimeScope を持ったシーンを追加するサンプルを作成しました。

1つ目のシーン

・RootParentLifetimeScope.cs

using VContainer;
using VContainer.Unity;

namespace EnqueueParentSample
{
    public class RootParentLifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            // HelloWorldServiceをDIコンテナに登録する
            builder.Register<HelloWorldService>(Lifetime.Singleton);
        }
    }
}


2つ目のシーン

・GameLifetimeScope.cs

using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace EnqueueParentSample
{
    public class GameLifetimeScope : LifetimeScope
    {
        [SerializeField]
        private HelloScreen helloScreen;

        protected override void Configure(IContainerBuilder builder)
        {
            // GamePresenterをVContainerのEntryPointに登録する
            builder.RegisterEntryPoint<GamePresenter>();
            // HelloScreenコンポーネントをDIコンテナに登録する
            builder.RegisterComponent<HelloScreen>(helloScreen);
        }
    }
}

GameLifetimeScope.cs は HelloWorldService を参照する必要があるので、RootParentLifetimeScope.cs の子 LifetimeScope である必要があります。

シーンの遷移処理

まずは以下の通り、普通にシーンを追加するのみの以下のスクリプトを作成しました。
1つ目のシーンが起動すると同時に LoadScene 関数が呼び出されて2つ目のシーンが追加される形で開きます。
・NextSceneLoader.cs

using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer.Unity;

namespace EnqueueParentSample
{
    public class NextSceneLoader : MonoBehaviour
    {
        void Start()
        {
            SceneManager.LoadScene("NextEnqueueParentSample", LoadSceneMode.Additive);
        }
    }
}

合わせてシーン参照のため、BuildSettingsにシーンを登録しておきます。

動作確認

この状態でシーンを再生してみます。
すると LifetimeScope はシーン間では参照できないため、以下のエラーが発生します。

VContainerException: Failed to resolve EnqueueParentSample.GamePresenter : No such registration of type: EnqueueParentSample.HelloWorldService


EnqueueParentを使ってシーンを跨いで親LifetimeScopeを設定する

次にシーンを遷移するスクリプトを以下の通り、修正しました。
・NextSceneLoader.cs

using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer.Unity;

namespace EnqueueParentSample
{
    public class NextSceneLoader : MonoBehaviour
    {
        [SerializeField]
        private LifetimeScope firstSceneLifetimeScope;

        void Start()
        {
            LifetimeScope.EnqueueParent(firstSceneLifetimeScope);
            SceneManager.LoadScene("NextEnqueueParentSample", LoadSceneMode.Additive);
        }
    }
}

firstSceneLifetimeScope 変数には1つ目のシーンの LifetimeScope の参照を設定します。

LifetimeScope.EnqueueParent は指定の LifetimeScope を親として登録する関数です。
このため、2つ目のシーンの LifetimeScope が1つ目の LifetimeScope で登録した HelloWorldService を参照できるようになります。
vcontainer.hadashikick.jp

動作確認

シーンを再生して確認してみます。
参照が正常に解決され、エラーが発生せずにスクリプトが正常に動作するようになりました。

VContainerのHelloWorldを試す その4(Project root LifetimeScopeを設定する)

本日は VContainer の小ネタ枠です。
Project root LifetimeScopeを設定する手順について記事に残します。

前回記事

以下の前回記事のプロジェクトを利用します。
bluebirdofoz.hatenablog.com

Project root LifetimeScopeを設定する

Project root LifetimeScope は全ての LifetimeScope の親になる LifetimeScope です。
vcontainer.hadashikick.jp

Project root LifetimeScope と子の LifetimeScope を作成する

親となる Project root LifetimeScope とその子になる LifetimeScope を作成します。
以下の2つの LifetimeScope を用意しました。
・RootParentLifetimeScope.cs

using VContainer;
using VContainer.Unity;

namespace ProjectRootSample
{
    public class RootParentLifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            // HelloWorldServiceをDIコンテナに登録する
            builder.Register<HelloWorldService>(Lifetime.Singleton);
        }
    }
}

・GameLifetimeScope.cs

using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace ProjectRootSample
{
    public class GameLifetimeScope : LifetimeScope
    {
        [SerializeField]
        private HelloScreen helloScreen;

        protected override void Configure(IContainerBuilder builder)
        {
            // GamePresenterをVContainerのEntryPointに登録する
            builder.RegisterEntryPoint<GamePresenter>();
            // HelloScreenコンポーネントをDIコンテナに登録する
            builder.RegisterComponent<HelloScreen>(helloScreen);
        }
    }
}

GameLifetimeScope.cs は HelloWorldService を参照する必要があるので、RootParentLifetimeScope.cs の子 LifetimeScope である必要があります。

Project root LifetimeScope を設定するため、この LifetimeScope をアタッチしたプレハブを作成します。

Assets で右クリックから[Create -> VContainer -> VContainerSettings]を実行して適当なフォルダに VContainerSettings を作成します。


作成した VContainerSettings の[RootLifetimeScope]に先ほどのプレハブを登録します。

VContainerSettings は作成と同時にプロジェクトの[PreloadAssets]に登録されており、本設定はプロジェクト全体で有効です。

動作確認

シーン内に GameLifetimeScope.cs のみアタッチしたシーンを再生して動作を確認します。
自動的に親 LifetimeScope として RootParentLifetimeScope.cs が動作するため、参照が解決して正常に動作します。

試しに VContainerSettings のプレハブ設定を削除して再び再生してみます。

以下の通り、HelloWorldService の参照を解決できずエラーが発生しました。

Project root LifetimeScope はプロジェクトにプリロードされるため、シーンを跨いで参照することも可能です。

VContainerのHelloWorldを試す その3(クラスを自身のクラスとインタフェースの両方で登録する)

本日は VContainer の小ネタ枠です。
VContainer でクラスを自身のクラスとインタフェースの両方で登録するケースについて記事に残します。

前回記事

以下の前回記事の続きです。
bluebirdofoz.hatenablog.com

クラスを自身のクラスとインタフェースの両方で登録したいケース

例えば HelloWorldService を以下の通り修正しました。
HelloWorldService はロジックを保持するだけでなく IStatable を使って初回のみ自身の処理を実行するようになりました。
処理を実行するたびにその実行回数を表示するログを出力します。
・HelloWorldService.cs

using VContainer.Unity;

namespace ImplementedSample
{
    public class HelloWorldService : IStartable
    {
        private int count = 0;

        public void Hello()
        {
            count++;
            UnityEngine.Debug.Log($"Hello world : {count}");
        }

        public void Start()
        {
            Hello();
        }
    }
}

HelloWorldService をエントリポイントに登録するため、GameLifetimeScope も RegisterEntryPoint で HelloWorldService を登録するよう変更します。
・GameLifetimeScope.cs

sing UnityEngine;
using VContainer;
using VContainer.Unity;

namespace ImplementedSample
{
    public class GameLifetimeScope : LifetimeScope
    {
        [SerializeField]
        private HelloScreen helloScreen;

        protected override void Configure(IContainerBuilder builder)
        {
            // HelloWorldServiceをVContainerのEntryPointに登録する
            builder.RegisterEntryPoint<HelloWorldService>();
            // GamePresenterをVContainerのEntryPointに登録する
            builder.RegisterEntryPoint<GamePresenter>();
            // HelloScreenコンポーネントをDIコンテナに登録する
            builder.RegisterComponent<HelloScreen>(helloScreen);
        }
    }
}

この変更を行ってシーンを再生すると以下のエラーが発生します。

VContainerException: Failed to resolve ImplementedSample.GamePresenter : No such registration of type: ImplementedSample.HelloWorldService

これは Register での登録を RegisterEntryPoint に変更したことで、HelloWorldService がクラスとして登録されなくなったためです。
GamePresenter のコンストラクタで HelloWorldService が解決できなくなっています。

RegisterとRegisterEntryPointで登録する

では改めてクラスとしても登録するため、Register と RegisterEntryPoint の両方で登録するように GameLifetimeScope を変更します。
・GameLifetimeScope.cs

using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace ImplementedSample
{
    public class GameLifetimeScope : LifetimeScope
    {
        [SerializeField]
        private HelloScreen helloScreen;

        protected override void Configure(IContainerBuilder builder)
        {
            // HelloWorldServiceをDIコンテナに登録する
            builder.Register<HelloWorldService>(Lifetime.Singleton);
            // HelloWorldServiceをVContainerのEntryPointに登録する
            builder.RegisterEntryPoint<HelloWorldService>();
            // GamePresenterをVContainerのEntryPointに登録する
            builder.RegisterEntryPoint<GamePresenter>();
            // HelloScreenコンポーネントをDIコンテナに登録する
            builder.RegisterComponent<HelloScreen>(helloScreen);
        }
    }
}

この変更を行ってシーンを再生するとエラーは発生しなくなります。
しかし、以下の通り初回分の実行カウントが正しくインクリメントされていないように見える事象が発生します。

これは Register と RegisterEntryPoint で別々のインスタンスが生成されて別々に参照されてしまうためです。

1つのインスタンスに対して自身のクラスとインタフェースの両方で登録する

1つのインスタンスに対してクラスの登録とエントリポイントの登録をしたい場合、AsImplementedInterfaces と AsSelf を利用します。
以下の通り、GameLifetimeScope を変更しました。
・GameLifetimeScope.cs

using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace ImplementedSample
{
    public class GameLifetimeScope : LifetimeScope
    {
        [SerializeField]
        private HelloScreen helloScreen;

        protected override void Configure(IContainerBuilder builder)
        {
            // HelloWorldServiceをDIコンテナに登録する
            builder.Register<HelloWorldService>(Lifetime.Singleton).AsImplementedInterfaces().AsSelf();
            // GamePresenterをVContainerのEntryPointに登録する
            builder.RegisterEntryPoint<GamePresenter>();
            // HelloScreenコンポーネントをDIコンテナに登録する
            builder.RegisterComponent<HelloScreen>(helloScreen);
        }
    }
}

この変更を行ってシーンを再生すると以下の通り、初回分の実行カウントが正しくインクリメントされるようになりました。

AsImplementedInterfaces

AsImplementedInterfaces は対象のクラスが持つ Interface と関連付けてインスタンスを登録します。
今回 HelloWorldService は IStartable のみインタフェースを実装しているので以下の記述と同義ということになります。

builder.Register<HelloWorldService>(Lifetime.Singleton).AsImplementedInterfaces();
builder.Register<HelloWorldService>(Lifetime.Singleton).As<IStartable>();

AsSelf

AsSelf は対象のクラスと関連付けてインスタンスを登録します。
AsImplementedInterfaces は対象のクラスとの関連付けは行わないので追加で指定する必要があります。

SDキャラクターの3Dモデルを作成する その44(VRMファイルに一人称視点の設定する)

本日はSDキャラクターの作成枠です。
SDホロラボちゃんの3Dモデル作成を進めていきます。
今回は VRM ファイルに一人称視点の設定する手順を記事にします。

一人称視点の設定

VRM ではモデルをアバターとして利用しているとき、司会の邪魔にならないように自身の視点から頭部のメッシュを不可視にできます。
この機能を有効にするには VRMFirstPerson を設定します。
vrm.dev

本設定を行う際は非表示にするメッシュと表示するメッシュを別オブジェクトとして分割しておく必要があります。
以前作成した python スクリプトBlender プロジェクトを修正して頭部オブジェクトと身体オブジェクトを分けた3Dモデルを作成しました。
・finishing_sdhololabchan.py

# bpyインポート
import bpy
# 正規表現を利用するためインポート
import re

def apply_modifier_targettype(arg_object:bpy.types.Object, arg_modifier_type:str) -> bool:
    """指定オブジェクトの指定タイプのモディファイアを適用する

    Keyword Arguments:
        arg_object (bpy.types.Object): 指定オブジェクト
        arg_modifier_type {str} -- 指定のモディファイアタイプ

    Returns:
        bool -- 実行成否
    """
    
    # 対象のオブジェクトをアクティブオブジェクトにする
    bpy.context.view_layer.objects.active = arg_object

    # オブジェクト内の全てのモディファイアを走査する
    for check_modifier in arg_object.modifiers:
        # モディファイアの種類が指定のものであるか確認する
        if check_modifier.type == arg_modifier_type:
            # 指定モディファイアなら適用する
            # (https://docs.blender.org/api/current/bpy.ops.object.html#bpy.ops.object.modifier_add)
            bpy.ops.object.modifier_apply(modifier=check_modifier.name)

    # 実行成否を返却する
    return True

# 指定の正規表現とオブジェクト名が一致したオブジェクトの参照リストを返す
def select_object_patternmatch(arg_pattern:str) -> list:
    """指定の正規表現とオブジェクト名が一致したオブジェクトの参照リストを返す

    Keyword Arguments:
        arg_pattern {str} -- 指定文字列

    Returns:
        list -- オブジェクトの参照リスト
    """
    
    # 処理対象のオブジェクトリストを作成する
    targetobject_list = []

    # シーン内の全オブジェクトを走査する
    for check_obj in bpy.context.scene.objects:
        # オブジェクト名が指定の正規表現と一致するか
        if re.fullmatch(arg_pattern, check_obj.name):
            # 一致すれば対象とする
            targetobject_list.append(check_obj)

    # リストを返却する
    return targetobject_list

# 選択中オブジェクトを結合する
def join_objects_selected(arg_objectname="") -> bool:
    """選択中オブジェクトを結合する
    
    Keyword Arguments:
        arg_objectname {str} -- 結合オブジェクト名 (default: {""})

    Returns:
        bool -- 実行の正否
    """

    # オブジェクトの結合を実行する
    bpy.ops.object.join()
    
    # 結合オブジェクト名が設定されているか
    if len(arg_objectname):
        # オブジェクト名が設定されていれば名前を変更する
        bpy.context.view_layer.objects.active.name = arg_objectname
  
    return True

# 関数の実行例
# シーン内の全オブジェクトを走査する
for check_obj in bpy.context.scene.objects:
    # 一旦すべてのオブジェクトを非選択状態にする
    check_obj.select_set(False)

# 指定の正規表現(Joint_Head_~)とオブジェクト名が一致したオブジェクトを処理対象にする
targetobj_list = select_object_patternmatch(r"Joint_Head_.*")

# 処理対象のオブジェクトのジオメトリ系モディファイアを適用する
for target_obj in targetobj_list:
    # ミラーモディファイアを適用する
    apply_modifier_targettype(target_obj, 'MIRROR')
    # 辺分離モディファイアを適用する
    apply_modifier_targettype(target_obj, 'EDGE_SPLIT')
    # 対象のオブジェクトを選択状態にする
    target_obj.select_set(True)

# オブジェクトを結合する
if len(targetobj_list) > 0:
    # アクティブオブジェクトを指定する
    bpy.context.view_layer.objects.active = targetobj_list[0]
    # オブジェクトを結合する
    join_objects_selected("Head")

for check_obj in bpy.context.scene.objects:
    # 一旦すべてのオブジェクトを非選択状態にする
    check_obj.select_set(False)

# 指定の正規表現(Joint_Body_~)とオブジェクト名が一致したオブジェクトを処理対象にする
targetobj_list = select_object_patternmatch(r"Joint_Body_.*")

# 処理対象のオブジェクトのジオメトリ系モディファイアを適用する
for target_obj in targetobj_list:
    # ミラーモディファイアを適用する
    apply_modifier_targettype(target_obj, 'MIRROR')
    # 辺分離モディファイアを適用する
    apply_modifier_targettype(target_obj, 'EDGE_SPLIT')
    # 対象のオブジェクトを選択状態にする
    target_obj.select_set(True)

# オブジェクトを結合する
if len(targetobj_list) > 0:
    # アクティブオブジェクトを指定する
    bpy.context.view_layer.objects.active = targetobj_list[0]
    # オブジェクトを結合する
    join_objects_selected("Body")

for check_obj in bpy.context.scene.objects:
    # 一旦すべてのオブジェクトを非選択状態にする
    check_obj.select_set(False)

# 指定の正規表現(Unnecessary_~)とオブジェクト名が一致したオブジェクトを処理対象にする
targetobj_list = select_object_patternmatch(r"Unnecessary_.*")

# 処理対象のオブジェクトを削除する
for target_obj in targetobj_list:
    # オブジェクトが存在する場合は削除を行う
    bpy.data.objects.remove(target_obj)

3Dモデルを変更したので改めて以下の手順を実施して VRM ファイルを再作成します。
bluebirdofoz.hatenablog.com

以下の通り、頭部と身体でオブジェクトを分離した VRM ファイルを再作成できました。
ここから VRMFirstPerson の設定を行います。

取り込んだ VRM ファイルの prefab を開きます。

ルートオブジェクトに設定されている VRMFirstPerson の[Renderers]設定を以下の通り変更します。

一人称でも三人称でも表示するオブジェクト(身体オブジェクト):Both(どちらでも表示)
一人称表示しないオブジェクト(頭部オブジェクト):ThirdPersonOnly(三人称でのみ表示)

これで設定は完了です。
メニューから[VRM -> Export to VRM]を実行して VRM ファイルを出力します。

IEquatableでClassの比較を実装する

本日は C# の小ネタ枠です。
IEquatable で Class の等価性チェックを実装する方法についてです。

前回記事

以下の前回記事の付随記事です。
bluebirdofoz.hatenablog.com

Classの比較処理を実装する

前回記事で Class のみ ReactiveProperty の差分チェックが意図通りに動作しませんでした。
何らかの理由で Struct や Record が利用できない場合は IEquatable インタフェースの Equals を実装して比較の方法を指定できます。

値の比較が行われるように前回のコードを変更してみました。
・ReactivePropertyTest2.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;

public class ReactivePropertyTest2 : MonoBehaviour
{
    /// <summary>
    /// Class
    /// </summary>
    private class CheckClass : System.IEquatable<CheckClass>
    {
        public bool onoff;

        /// <summary>
        ///  比較処理
        /// </summary>
        /// <param name="other"></param>
        /// <returns></returns>
        public bool Equals(CheckClass other)
        {
            return onoff == other.onoff;
        }
    }

    // 各ReactiveProperty変数
    private ReactiveProperty<CheckClass> checkClassProp = new ReactiveProperty<CheckClass>(new CheckClass());

    void Start()
        
    {
        // Class 変更検知時の処理
        checkClassProp
            .Subscribe(checkstate => { Debug.Log($"CheckClass Changed! : {checkstate.onoff}"); })
            .AddTo(this);
    }

    /// <summary>
    /// 全ての bool を True に変更
    /// </summary>
    [ContextMenu("ON")]
    void ChangeOn()
    {
        ChangeStates(true);
    }

    /// <summary>
    /// 全ての bool を False に変更
    /// </summary>
    [ContextMenu("OFF")]
    void ChangeOff()
    {
        ChangeStates(false);
    }

    void ChangeStates(bool isOnOff)
    {
        var checkClass = new CheckClass() { onoff = isOnOff };
        checkClassProp.Value = checkClass;
    }
}

以下の通り、これで false -> false への変化の場合で通知が発生しないようになりました。

UniRxのReactivePropertyでClass,Struct,Recordでの差分チェックの動作を確認する

本日は UniRx の小ネタ枠です。
UniRx の ReactiveProperty でそれぞれ Class, Struct, Record での差分チェックの動作が分からず確認したので記事に残します。

ReactivePropertyの値の変更

UniRx の ReactiveProperty を使うと値の変更を検知して特定の処理を行うといったコードを簡単に記述することができます。
今回はの「値の変更」の検知条件について Class, Struct, Record での違いを確認しました。

以下のサンプルコードを作成しました。
bool 型の変数を持たせた各定義の値を代入しなおしたとき、bool 値が変化した場合のみ通知が発生するか否かを検証します。
・ReactivePropertyTest

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;

public class ReactivePropertyTest : MonoBehaviour
{
    /// <summary>
    /// Class
    /// </summary>
    private class CheckClass
    {
        public bool onoff;
    }

    /// <summary>
    /// Struct
    /// </summary>
    private struct CheckStruct
    {
        public bool onoff;
    }

    /// <summary>
    /// Record
    /// </summary>
    private record CheckRecord
    {
        public bool onoff;
    }

    // 各ReactiveProperty変数
    private ReactiveProperty<CheckClass> checkClassProp = new ReactiveProperty<CheckClass>(new CheckClass());
    private ReactiveProperty<CheckStruct> checkStructProp = new ReactiveProperty<CheckStruct>(new CheckStruct());
    private ReactiveProperty<CheckRecord> checkRecordProp = new ReactiveProperty<CheckRecord>(new CheckRecord());

    void Start()
        
    {
        // Class 変更検知時の処理
        checkClassProp
            .Subscribe(checkstate => { Debug.Log($"CheckClass Changed! : {checkstate.onoff}"); })
            .AddTo(this);

        // Struct 変更検知時の処理
        checkStructProp
            .Subscribe(checkstate => { Debug.Log($"CheckStruct Changed! : {checkstate.onoff}"); })
            .AddTo(this);

        // Record 変更検知時の処理
        checkRecordProp
            .Subscribe(checkstate => { Debug.Log($"CheckRecord Changed! : {checkstate.onoff}"); })
            .AddTo(this);
    }

    /// <summary>
    /// 全ての bool を True に変更
    /// </summary>
    [ContextMenu("ON")]
    void ChangeOn()
    {
        ChangeStates(true);
    }

    /// <summary>
    /// 全ての bool を False に変更
    /// </summary>
    [ContextMenu("OFF")]
    void ChangeOff()
    {
        ChangeStates(false);
    }

    void ChangeStates(bool isOnOff)
    {
        var checkClass = new CheckClass() { onoff = isOnOff };
        checkClassProp.Value = checkClass;

        var checkStruct = new CheckStruct() { onoff = isOnOff };
        checkStructProp.Value = checkStruct;

        var checkRecord = new CheckRecord() { onoff = isOnOff };
        checkRecordProp.Value = checkRecord;
    }
}

結果として以下のような動作となりました。
・false -> true への変化の場合、Class, Struct, Record 全てで通知が発生する
・false -> false への変化の場合、Class でのみ通知が発生する


仕組み

RectiveProperty のソースコードを確認すると値の変更チェックには Equals が利用されています。

Classの場合

Class の定義では Equeals の処理をオーバーライドしていない場合、参照先の比較が行われます。
false -> false の変更でも値を代入しなおしているため、参照先が変わっており、通知が発生しています。

Structの場合

Struct は値型のため、デフォルトで値の比較が行われます。
このため、false -> true の変更でのみ通知が発生しています。

Recordの場合

Record は参照型ですが、Equals の処理で値の比較が行われます。
このため、false -> true の変更でのみ通知が発生しています。
learn.microsoft.com