🏃

軽量化手法24選【Unity】

に公開

MonoBehaviour / Unity API

1. GetComponent系はAwakeでキャッシュする

問題

GetComponent<T>()Update() の中で毎フレーム呼び出すと、フレームレートに比例してコストが積み重なります。また、.transformなどを呼び出すのも内部的にGetComponent系を呼んでいるようで、同様のコストになります。

なぜ遅いのか

GetComponent<T>() は毎回コンポーネントのリストを検索しています。1回あたりは小さくても、60fpsなら1秒間に60回実行されます。これが複数のオブジェクトで起きると無視できない負荷になります。

解決策

Awake() で一度だけ取得してフィールドに保存しておきます。Inspectorで直接設定できる場合は [SerializeField] を使うと、検索コスト自体をなくせます。

// Bad
void Update()
{
    GetComponent<Rigidbody>().AddForce(Vector3.up);
}

// Good:Awakeで1回だけ取得して保存
private Rigidbody _rb;

void Awake()
{
    _rb = GetComponent<Rigidbody>();
}

void Update()
{
    _rb.AddForce(Vector3.up);
}

2. GameObject.Find系の使用

問題

GameObject.Find()FindObjectOfType<T>() を呼ぶと、シーン内の全オブジェクトを調べに行くため、オブジェクトが増えるほど遅くなります。

なぜ遅いのか

これらのメソッドはシーン全体をひとつひとつ調べます。オブジェクトが100個でも1000個でもコードは動いてしまうので、気づかないまま重くなりがちです。

解決策

[SerializeField] でInspectorから直接アサインするか、Awake()Start() の起動時に一度だけ取得して使い回します。

// Bad
void Update()
{
    var player = GameObject.Find("Player");
    player.transform.position = Vector3.zero;
}

// Good:あらかじめInspectorで設定しておく
[SerializeField] private Transform _player;

void Update()
{
    _player.position = Vector3.zero;
}

3. Update()の処理を最小限にして、ロジックは別クラスに切り出す

問題

すべての処理を Update() に書き続けると、毎フレーム不要な計算まで走り続ける構造になります。MonoBehaviourにロジックが集中しすぎると、テストもしにくくなります。

なぜ問題なのか

Update() はUnityが毎フレーム自動で呼び出します。条件によっては不要な処理も毎回評価されてしまいます。またアクティブなMonoBehaviourの数が増えると、Unity内部での呼び出しコストも積み上がります。

解決策

状態が変わったときだけ処理を走らせたいなら、イベントやdelegateで呼び出す形にします。ゲームのロジック部分はUnityに依存しない純粋なC#クラスに切り出し、MonoBehaviourは入出力の橋渡し役に徹します。

// Bad:毎フレーム条件チェックしている
void Update()
{
    if (_isEnemyNear)
    {
        RecalculatePath();
    }
}

// Good:状態が変わったときだけ呼び出す
void OnEnemyEntered()
{
    RecalculatePath();
}
// Bad:MonoBehaviourにロジックが詰め込まれている
public class PlayerController : MonoBehaviour
{
    void Update()
    {
        // 移動・攻撃・スコア計算が混在
    }
}

// Good:ロジックをMonoBehaviourに依存しないクラスに分離
public class PlayerLogic
{
    public Vector3 CalculateMovement(Vector3 input, float speed) { ... }
}

public class PlayerController : MonoBehaviour
{
    private PlayerLogic _logic = new PlayerLogic();

    void Update()
    {
        var input = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        transform.position += _logic.CalculateMovement(input, 5f);
    }
}

4. タグ比較にはCompareTag()を使う

問題

gameObject.tag == "Enemy" は動きますが、毎フレーム多くのオブジェクトで行うとGCへの負荷がかかります。
ただし、2022.2以降だと==での比較とCompareTagが最適化されているようなので、最新環境だとパフォーマンス上の差がなくなっているようです。

なぜ問題なのか

== による文字列比較はC#の通常の比較処理で、内部でメモリの確保が起きます。CompareTag() はUnity内部で直接比較するため、そのコストがありません。

解決策

タグの比較は必ず CompareTag() を使います。

// Bad
if (other.gameObject.tag == "Enemy")

// Good
if (other.gameObject.CompareTag("Enemy"))

メモリ・GC

5. 頻繁に生成・破棄するオブジェクトはオブジェクトプールで管理する

問題

弾やエフェクトなど、短い間隔で Instantiate()Destroy() を繰り返すとGCが頻繁に動き、フレームレートが乱れる原因になります。

なぜ問題なのか

Instantiate() はオブジェクトをヒープ(メモリ)に新しく確保し、Destroy() はそれを解放します。この繰り返しはメモリの断片化を招き、GCが動くタイミングでフレームが一瞬止まります。

解決策

あらかじめオブジェクトをまとめて生成しておき、使いたいときはSetActive(true)で取り出し、使い終わったらSetActive(false)で返すというプール方式にします。Unity 2021以降はUnityEngine.Pool名前空間に標準のオブジェクトプールが用意されています。

using UnityEngine;
using UnityEngine.Pool;

public class BulletSpawner : MonoBehaviour
{
    [SerializeField] private Bullet _bulletPrefab;
    private ObjectPool<Bullet> _pool;

    void Awake()
    {
        _pool = new ObjectPool<Bullet>(
            createFunc: () => Instantiate(_bulletPrefab),
            actionOnGet: bullet => bullet.gameObject.SetActive(true),
            actionOnRelease: bullet => bullet.gameObject.SetActive(false),
            actionOnDestroy: bullet => Destroy(bullet.gameObject)
        );
    }

    public void Fire(Vector3 position)
    {
        var bullet = _pool.Get();
        bullet.transform.position = position;
        bullet.Initialize(_pool); // 使い終わったらプールに返す処理をBullet側に持たせる
    }
}

6. LINQはゲームプレイ中のパスで使わない

問題

Where()Select() などLINQのメソッドは便利ですが、毎フレーム呼ばれる処理の中で使うとGCアロケーションが発生し続けます。

なぜ問題なのか

LINQは内部でイテレータオブジェクトを生成します。このオブジェクトはヒープに確保されるため、呼ばれるたびにGCの仕事が増えます。起動時やデータの初期化など低頻度の処理で使う分には問題ありません。

解決策

毎フレーム通る処理では forforeach で書き直します。初期化時など一度しか呼ばれない処理であればLINQをそのまま使って問題ありません。

// Bad:Updateの中でLINQを使っている
void Update()
{
    var activeEnemies = _enemies.Where(e => e.IsAlive).ToList();
    foreach (var enemy in activeEnemies) { ... }
}

// Good:素直なforループに書き直す
void Update()
{
    for (int i = 0; i < _enemies.Count; i++)
    {
        if (_enemies[i].IsAlive) { ... }
    }
}

7. 毎フレームnewでオブジェクトを生成しない

問題

new List<T>()new Vector3() のような生成処理を Update() の中に書くと、毎フレームメモリを確保し続けることになります。

なぜ問題なのか

クラスのインスタンスを new するたびにヒープにメモリが確保されます。使い終わったらGCが回収しますが、このタイミングでフレームが止まります。構造体(Vector3 など)はメソッド内のローカル変数として宣言された場合、スタックに積まれるので影響は少ないですが、クラスは注意が必要です。

解決策

リストなどは事前にフィールドとして確保しておき、使う前に Clear() して再利用します。

// Bad:毎フレーム新しいリストを生成している
void Update()
{
    var targets = new List<Enemy>();
    // targetsを使った処理...
}

// Good:フィールドとして持ち、Clear()して再利用
private readonly List<Enemy> _targets = new List<Enemy>();

void Update()
{
    _targets.Clear();
    // _targetsを使った処理...
}

8. 文字列の結合にはStringBuilderか補間文字列を使う

問題

+ 演算子で文字列をつなぐと、つなぐたびに新しい文字列オブジェクトがヒープに作られます。

なぜ問題なのか

C#の文字列はイミュータブル(変更不可)です。"Hello" + name + "!" という式は、中間の文字列を含めて複数のオブジェクトを生成します。スコアやタイマーなど毎フレーム更新する表示値の処理で繰り返すと、GCへの負荷が積み上がります。

解決策

複数行にわたる結合は StringBuilder を使います。単純な1行の結合は補間文字列($"...")が読みやすく、コンパイラが最適化してくれます。ただし補間文字列も状況によってはアロケーションが発生するため、毎フレーム呼ばれる箇所では StringBuilder が安全です。

// Bad
string result = "Score: " + _score + " / " + _maxScore;

// Good:補間文字列(低頻度な処理では十分)
string result = $"Score: {_score} / {_maxScore}";

// Better:毎フレーム更新する場合はStringBuilderで再利用
private readonly StringBuilder _sb = new StringBuilder();

void UpdateScoreText()
{
    _sb.Clear();
    _sb.Append("Score: ");
    _sb.Append(_score);
    _sb.Append(" / ");
    _sb.Append(_maxScore);
    _scoreText.text = _sb.ToString();
}

データ構造

9. 検索・存在確認が多いコレクションはDictionary / HashSetに切り替える

問題

List<T>Contains()Find() はリストの先頭から順番に調べるため、要素数が増えると遅くなります。

なぜ遅いのか

List<T>.Contains() は最悪の場合、全要素を調べてから「ない」と判断します(O(n))。DictionaryHashSet はハッシュを使って一発で探せるため、要素数が増えても速さがほぼ変わりません(O(1))。

解決策

「このIDのアイテムを持っているか」「このオブジェクトは登録済みか」のような確認処理が多い場合は、Dictionary<TKey, TValue>HashSet<T> に切り替えます。

// Bad:毎回リストを全部調べる
private List<int> _collectedItemIds = new List<int>();
bool HasItem(int id) => _collectedItemIds.Contains(id); // O(n)

// Good:HashSetなら瞬時に判定できる
private HashSet<int> _collectedItemIds = new HashSet<int>();
bool HasItem(int id) => _collectedItemIds.Contains(id); // O(1)

10. 早期リターンで不要な処理をスキップする

問題

条件チェックをネストで重ねると読みにくくなるうえ、無駄な処理まで実行されることがあります。

なぜ問題なのか

条件が満たされないケースでも処理の本体まで到達してしまう書き方だと、毎フレームの無駄なコストになります。また深いネストはコードの意図が読み取りにくく、バグの温床にもなります。

解決策

処理できない条件を先にチェックして早めに return します。処理の本体はメソッドの浅い位置に置くのが基本です。

// Bad:ネストが深い
void TakeDamage(int damage)
{
    if (_isAlive)
    {
        if (!_isInvincible)
        {
            _hp -= damage;
            // ダメージ処理...
        }
    }
}

// Good:早期リターンで本処理をフラットに書く
void TakeDamage(int damage)
{
    if (!_isAlive) return;
    if (_isInvincible) return;

    _hp -= damage;
    // ダメージ処理...
}

数値計算

11. 距離の大小比較にはsqrMagnitudeを使う

問題

Vector3.Distance() で距離を比較するコードは直感的ですが、内部で平方根の計算を行っており、毎フレーム多用すると無駄なコストになります。

なぜ問題なのか

平方根(Mathf.Sqrt)はCPUへの負荷が高い演算です。「AよりBが近いか」という大小比較だけが目的なら、平方根を取らなくても二乗のまま比べれば同じ結果が得られます。

解決策

距離の値そのものが必要な場合を除き、大小比較には sqrMagnitude を使います。比較する閾値も二乗しておくのを忘れずに。

// Bad:毎回平方根を計算している
float dist = Vector3.Distance(transform.position, target.position);
if (dist < 5f) { ... }

// Good:二乗のまま比較する
float sqrDist = (transform.position - target.position).sqrMagnitude;
if (sqrDist < 5f * 5f) { ... }

設計

12. MonoBehaviourは入出力の橋渡し役に徹し、ロジックは純粋C#クラスに持つ

問題

MonoBehaviourの中にゲームのロジックを直接書き続けると、コードが肥大化し、テストも再利用もしにくくなります。

なぜ問題なのか

MonoBehaviourはUnityのライフサイクルや描画に強く依存しています。ここにビジネスロジックが混ざると、Unityを起動しないと動作確認できない・他のシーンで使い回せないという問題が生じます。またアクティブなMonoBehaviourの数が多いほど、Unityの内部コストも増えます。

解決策

ゲームのルールや計算などのロジックはMonoBehaviourに依存しない純粋なC#クラスに切り出します。MonoBehaviourはUnity APIの呼び出しと、そのクラスへの入出力だけを担当させます。

【役割の分け方】

MonoBehaviour(PlayerController)
  └─ 入力の受け取り(Input)
  └─ 結果をTransformやAnimatorに反映
  └─ ロジッククラスの呼び出し

C#クラス(PlayerLogic)
  └─ 移動量の計算
  └─ HPの管理
  └─ 状態の判定

このようにすると、PlayerLogic の単体テストが書けるようになり、MonoBehaviourのコードも薄くなります。


13. ScriptableObjectをデータ・イベント・設定値の管理に使う

問題

ゲームのパラメータをMonoBehaviourやPrefabの中に直接書くと、変更のたびにPrefabを開いて編集する手間が増え、シーンをまたいだ参照も難しくなります。

なぜ問題なのか

Prefabやシーンに埋め込まれたデータは、参照関係が複雑になりやすく、シーン間での共有もFindやSingletonに頼りがちになります。

解決策

ScriptableObject(SO)を以下の3つの用途で活用します。

① データとして使う(マスターデータ)

敵のステータスやアイテムのパラメータなどをSOに持たせると、コードから完全に分離してInspectorで編集できます。

[CreateAssetMenu(fileName = "EnemyData", menuName = "Data/Enemy")]
public class EnemyData : ScriptableObject
{
    public int maxHp;
    public float moveSpeed;
    public int attackPower;
}

② イベントチャンネルとして使う(シーン間の通知)

SOをイベントの中継地点にすると、送り手と受け手がお互いを知らなくてもやり取りできます(Ryan Hipple方式)。

// イベントチャンネルSO
[CreateAssetMenu]
public class GameEventSO : ScriptableObject
{
    private readonly List<Action> _listeners = new();

    public void Raise() => _listeners.ForEach(l => l.Invoke());
    public void Register(Action listener) => _listeners.Add(listener);
    public void Unregister(Action listener) => _listeners.Remove(listener);
}

③ 設定値として使う(ゲームバランスの管理)

移動速度やダメージ倍率などの数値をSOにまとめると、コードを触らずにバランス調整できます。


描画・UI

14. MaterialPropertyBlockでマテリアルの変更を最小限にする

問題

renderer.material.color = color; のようにマテリアルのプロパティを直接変更すると、そのオブジェクト専用のマテリアルインスタンスが毎回生成されます。

なぜ問題なのか

renderer.material にアクセスした時点で、Unityは新しいマテリアルインスタンスをヒープに作ります。同じマテリアルを使っていたオブジェクトがバラバラのインスタンスを持つことになり、ドローコールのバッチングも崩れます。

解決策

MaterialPropertyBlock を使うと、マテリアル本体を変えずにオブジェクトごとの見た目を変えられます。

// Bad:マテリアルインスタンスが毎回生成される
_renderer.material.color = Color.red;

// Good:MaterialPropertyBlockで変更する
private static readonly MaterialPropertyBlock _mpb = new MaterialPropertyBlock();
private static readonly int _colorId = Shader.PropertyToID("_Color");

void SetColor(Color color)
{
    _mpb.SetColor(_colorId, color);
    _renderer.SetPropertyBlock(_mpb);
}

15. 更新頻度の違うUI要素はCanvasを分ける

問題

ひとつのCanvasに動く要素と動かない要素が混在していると、一部が変わるだけで全体が再構築されてしまいます。

なぜ問題なのか

UnityのuGUIは、Canvas内のいずれかの要素が変化すると、そのCanvas全体をDirty(再構築が必要)とマークします。毎フレーム更新するゲージや数値と、ほとんど変わらないHUDの背景が同じCanvasにいると、常に全体の再構築が走ります。

解決策

更新頻度で要素をCanvasに分けます。たとえば「静的な背景・アイコン」と「毎フレーム変わるHP・スコア」で別々のCanvasにするだけで、再構築の範囲を大幅に絞れます。

【Canvas分割の例】

Canvas(静的)
  └─ 背景パネル
  └─ アイコン群
  └─ ほぼ変化しないラベル

Canvas(動的)
  └─ HPゲージ
  └─ スコア表示
  └─ タイマー

16. クリック判定が不要なUI要素はRaycast Targetをオフにする

問題

uGUIのImageやTextはデフォルトで Raycast Target がオンになっています。クリックやタップの判定が必要ない要素まで判定対象になると、毎フレームの入力チェックが無駄に重くなります。

なぜ問題なのか

uGUIはフレームごとにすべての Raycast Target が有効な要素をチェックしています。装飾用のImageや背景パネルなど、押す必要のない要素が大量にあると、そのぶん判定コストがかかります。

解決策

ボタンやトグルなど実際にインタラクションが必要な要素以外は、InspectorでRaycast Targetのチェックを外します。チーム開発であれば、デフォルトでオフにするカスタムImageコンポーネントを用意しておくと効果的です。


17. テキストにはTextMeshProを使う

問題

uGUI標準の Text コンポーネントは描画品質とパフォーマンスの両面でTextMeshPro(TMP)に劣ります。最新のものだとTMPが標準化されていますが、過去のバージョンを使用している場合にデフォルトのまま使っているとパフォーマンス低下につながる可能性があります。

なぜ問題なのか

標準の Text はテキスト内容や解像度が変わるたびにテクスチャを再生成します。TMPはSDF(Signed Distance Field)という技術を使っており、拡大縮小しても品質が落ちず、更新コストも低く抑えられています。

解決策

新規プロジェクトでは最初からTMPを使います。既存プロジェクトの場合も、頻繁に更新されるテキスト(スコア・タイマー等)から順に移行するだけで効果があります。UnityのメニューからWindow > TextMeshPro > Import TMP Essential Resourcesでインポートできます。


アセット管理・非同期

18. Resources.LoadはAddressablesに移行する

問題

Resources.Load() は呼び出した瞬間にアセットを同期的に読み込むため、読み込みが重いアセットだとそのフレームで処理が止まります。

なぜ問題なのか

同期ロードはロードが完了するまでメインスレッドが止まります。またResourcesフォルダに入れたアセットはビルド時にすべてパッケージに含まれるため、使わないアセットもサイズに加算されます。

解決策

Addressables(アドレサブルアセットシステム)を使うと、非同期での読み込みとメモリの明示的な管理ができます。

// Bad:同期ロードでフレームが止まる
var prefab = Resources.Load<GameObject>("Enemies/Goblin");

// Good:Addressablesで非同期ロード
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

async void LoadEnemy()
{
    var handle = Addressables.LoadAssetAsync<GameObject>("Enemies/Goblin");
    await handle.Task;

    if (handle.Status == AsyncOperationStatus.Succeeded)
    {
        Instantiate(handle.Result);
    }
}

19. ファイルIOや通信は非同期で行う

問題

セーブデータの読み書きやネットワーク通信をメインスレッドで同期的に行うと、処理が完了するまでゲームが完全に止まります。

なぜ問題なのか

ファイルIOやネットワーク通信は処理時間が外部要因に左右されます。ユーザーの端末環境やネットワーク状況によっては数秒かかることもあり、その間ゲームが固まった状態になります。

解決策

async/await を使って非同期で処理します。C#標準の Task またはUnity向けの UniTask(より軽量でUnity親和性が高い非同期処理)を使います。

// Bad:同期ロードでゲームが止まる
void LoadSaveData()
{
    var json = File.ReadAllText(savePath);
    _saveData = JsonUtility.FromJson<SaveData>(json);
}

// Good:非同期で読み込む
async UniTask LoadSaveDataAsync()
{
    var json = await File.ReadAllTextAsync(savePath);
    _saveData = JsonUtility.FromJson<SaveData>(json);
}

20. 複数アセットの読み込みは並列化する

問題

複数のアセットを順番に読み込むと、合計時間はそれぞれの読み込み時間の合計になります。互いに依存関係がないアセットなら、並列で読み込むことで全体の待ち時間を短縮できます。

なぜ効果があるのか

非同期処理を await で1件ずつ待つと、A→B→Cの順番で直列に実行されます。Task.WhenAll()UniTask.WhenAll() を使うとA・B・Cを同時に始め、全部終わったら次に進めます。

解決策

依存関係のない複数のアセット読み込みや通信は、WhenAll でまとめて並列実行します。

// Bad:順番に読み込んでいる(合計時間 = A + B + C)
var bgm = await LoadAudioAsync("BGM");
var atlas = await LoadSpriteAsync("Atlas");
var masterData = await LoadJsonAsync("MasterData");

// Good:まとめて並列実行(合計時間 ≒ max(A, B, C))
var (bgm, atlas, masterData) = await UniTask.WhenAll(
    LoadAudioAsync("BGM"),
    LoadSpriteAsync("Atlas"),
    LoadJsonAsync("MasterData")
);

入力

21. 旧Input Manager(Input.GetAxis)からInput Systemパッケージへ移行する

問題

旧来の Input.GetAxis("Horizontal") などのAPIは、毎フレーム文字列でアクション名を検索しています。

なぜ問題なのか

文字列ベースの検索は毎フレーム実行されるため、わずかながらGCアロケーションの要因になります。また入力の設定がコードに直書きされるため、キーバインドの変更や新しい入力デバイスへの対応が難しくなります。

解決策

Unity公式のInput Systemパッケージを使います。InputActionAsset(ScriptableObjectベース)で入力設定を一元管理でき、コードからは生成されたC#クラス経由で型安全にアクセスできます。

// Bad:文字列で毎フレーム検索
void Update()
{
    float h = Input.GetAxis("Horizontal");
    float v = Input.GetAxis("Vertical");
}

// Good:Input Systemで生成したクラスを使う
private PlayerInputActions _inputActions;

void Awake()
{
    _inputActions = new PlayerInputActions();
    _inputActions.Enable();
}

void Update()
{
    Vector2 move = _inputActions.Player.Move.ReadValue<Vector2>();
}

アニメーション

22. AnimatorのパラメータアクセスはIDでキャッシュする

問題

animator.SetBool("IsRunning", true) のように文字列でAnimatorのパラメータにアクセスすると、毎フレームの文字列検索コストがかかります。

なぜ問題なのか

Animatorへのパラメータアクセスは文字列でハッシュ変換を毎回行っています。Animator.StringToHash() で事前にIDを取得しておくと、この変換処理をスキップできます。

解決策

パラメータ名はAwakeなどで一度だけハッシュ化してフィールドに保存し、以降はIDで操作します。

// Bad:毎回文字列で検索
void Update()
{
    _animator.SetBool("IsRunning", _isRunning);
}

// Good:IDを事前にキャッシュしておく
private static readonly int IsRunningId = Animator.StringToHash("IsRunning");

void Update()
{
    _animator.SetBool(IsRunningId, _isRunning);
}

デバッグ・プロファイリング

23. Debug.Logを本番ビルドから除外する

問題

Debug.Log() はエディタ上でのデバッグには便利ですが、本番ビルドにそのまま残すとログを出力しない場合でも文字列の生成コストがかかります。

なぜ問題なのか

Debug.Log("Position: " + transform.position) のような呼び出しは、ログが出力されるかどうかに関わらず、文字列の結合処理がメインスレッドで実行されます。頻繁に呼ばれるパスにあると無視できないコストになります。

解決策

[Conditional] 属性でラップしたラッパーメソッドを用意するか、#if DEVELOPMENT_BUILD で囲みます。[Conditional] を使うと、条件が満たされないビルドでは呼び出し元のコードごとコンパイラが除去してくれるため、文字列生成も含めてコストがゼロになります。

// Bad:本番ビルドでも文字列が生成される
void Update()
{
    Debug.Log("Position: " + transform.position);
}

// Good:Conditionalでラップして本番ビルドから完全除外
public static class DebugUtil
{
    [System.Diagnostics.Conditional("DEVELOPMENT_BUILD")]
    [System.Diagnostics.Conditional("UNITY_EDITOR")]
    public static void Log(string message)
    {
        Debug.Log(message);
    }
}

void Update()
{
    DebugUtil.Log("Position: " + transform.position);
    // 本番ビルドではこの行ごと消える(文字列生成も発生しない)
}
[Conditional] と #if の詳細な使い分け

[Conditional] 属性の本質的な優位点は、呼び出し元のコードごと除去される点にあります。#if は囲んだブロックのみを除去しますが、[Conditional] は引数として渡している文字列結合やメソッド呼び出しなどの評価式も含めて消去されます。

一方、#if は呼び出しごとに記述が必要なため書き忘れが発生しやすく、[Conditional] はラッパーを1か所定義すれば呼び出し側に記述不要なため保守性に優れます。

ただし [Conditional] には制約があります。戻り値が void のメソッドにしか付与できず、副作用のある式を引数に渡すとその副作用ごと消えるため注意が必要です。

// 本番ビルドでは SomeMethodWithSideEffect() 自体が呼ばれない
DebugUtil.Log(SomeMethodWithSideEffect());

また、#if !UNITY_EDITOR はエディタ上でのみ除外が有効であり、実機でのDevelopment Buildではログが残ります。「本番除外」が目的であれば DEVELOPMENT_BUILD シンボルも併用する必要があります。[Conditional] に両シンボルを付与しているのはこのためです。

#if が適しているのは、ログ以外の処理ブロックを条件付きで除外したい場合や、[Conditional] の制約に引っかかるケースです。


24. 計測してから最適化する

問題

「なんとなく重そう」という感覚だけで最適化を進めると、実際のボトルネックではない場所に時間を使ってしまい、効果が出ないことがあります。

なぜ重要なのか

パフォーマンス問題の原因は、見た目や印象とは違う場所にあることがほとんどです。思い込みで最適化しても体感が変わらないケースは多く、最悪コードの可読性だけが下がります。「計測してから最適化する」はパフォーマンス改善の大原則です。

解決策

UnityにはProfilerとMemory Profilerという2つの強力な計測ツールがあります。まずこれらで実際のボトルネックを特定してから対処します。

  • Unity Profiler:CPU・GPU・メモリ・GCアロケーションをフレームごとに確認できます。Deep ProfileモードにするとC#メソッド単位まで詳細に掘り下げられます
  • Memory Profiler(パッケージ追加が必要):ヒープのスナップショットを比較して、どこでメモリが積み上がっているかを視覚的に確認できます
  • Frame Debugger:描画の各ステップを1ドローコールずつ確認でき、意図しないバッチング崩れなどを発見するのに役立ちます

最適化の手順としては「Profilerで計測 → ボトルネックを特定 → 対処 → 再計測で効果を確認」のサイクルを回すのが基本です。


Discussion