🎮

なぜUnity(C#)は遅いといわれるのか

に公開1件

この記事の目的

前堤:なぜ「C#は遅い」と言われるのか?

ゲーム開発界隈ではよく「C#は遅い」「Unityは重い」と言われる。
しかし実際には、C#自体が遅いのではなく、
メモリ管理モデルとリアルタイム性の相性に原因がある。

本記事では、実際のUnityプロジェクトと検証コードを用いて、
この「遅いと言われる正体」を数値ベースで検証していく。

0. GC Allocとは

GC Alloc とは、C# においてヒープ領域にメモリ確保が発生した量を表す指標である。

Unity では、ヒープ確保が一定量を超えるとガベージコレクション(GC)が実行され、

そのフレームでは処理が一時停止し、FPS低下やスパイク(画面のカクつき)の原因となる。

特に Update() のような毎フレーム呼ばれる処理内で GC Alloc が発生すると、

プレイ中に断続的なカクつきが発生しやすくなる。

そのため Unity では「Update 内で GC Alloc を発生させない設計」が

パフォーマンス最適化の基本方針となる。

ヒープ・GCについて
https://ufcpp.net/study/computer/MemoryManagement.html


1. 既存プロジェクトにおけるパフォーマンス調査

試しにこれまで私が作成したゲーム2種におけるGC AllocおよびCPU時間の調査を行った。

目安となるCPU時間

CPU時間(1フレーム分の処理を終えるまでにかかった時間) 理論FPS 状態
16.6 ms 60 FPS 一般的な目標値
8.3 ms 120 FPS かなり軽い
< 5.0 ms 200 FPS 非常に軽い

・迷宮クロノス(3D脱出ゲーム)

ゲームのリンク: https://unityroom.com/games/labyrinthchronos_mikku


・クリエッ!!!(2D育成シミュレーションゲーム)

ゲームのリンク: https://unityroom.com/games/crea-game_mikku

まとめ

2. サンプルプロジェクトによるパフォーマンスの検証

前章で扱ったゲームでは致命的なGC AllocやCPU時間を確認できなかったため、サンプルプロジェクトを作成し、悪い事例と良い事例を作成してみて、その違いの検証を行った。

1. 意図的な悪いコード

例: classを毎フレームnewする

この実装が問題なのは、「処理量」ではなく
「フレームごとに大量のヒープ確保が発生する構造」にある。

BadGCExample.cs
using System.Collections.Generic;
using UnityEngine;

public class BadGCExample : MonoBehaviour
{
    class Dummy
    {
        public float x, y, z;
    }

    void Update()
    {
        for (int i = 0; i < 10000; i++)
        {
            var d = new Dummy();
        }
    }
}

実行結果

class は参照型であり、new によって ヒープ領域にメモリ確保が発生する。
しかし、参照が切れた時点では即座に解放されない。

このサンプルでは Update() 内で毎フレーム 10,000 回 new を行っているため、

1フレームごとに大量のヒープアロケーションが発生している。

その結果、一気に未回収オブジェクトが溜まってGC Alloc が増加し、
一定間隔で GC が実行されることで、CPU時間が悪化し、FPSが不安定になる。


2. 良いコード(struct)

例: struct

GoodGCExample.cs
using System.Collections.Generic;
using UnityEngine

public class GoodGCExample : MonoBehaviour
{
    struct Dummy
    {
        public float x, y, z;
    }

    Dummy[] dummies = new Dummy[10000];

    void Update()
    {
        for (int i = 0; i < 10000; i++)
        {
            dummies[i].x = i;
            dummies[i].y = i * 2;
            dummies[i].z = Time.frameCount;
        }
    }
}

実行結果

structの配列自体はヒープ上に確保されるが、
要素の個々のデータは参照型オブジェクトを生成しないため、
要素操作による追加のGC Allocが発生しない。

そのため GC Alloc が 0 となり、GCによるフレーム停止が起きない。


3. 良いコード(object pool)

例: object pool

GoodGCExample.cs
using System.Collections.Generic;
using UnityEngine;

public class GoodGCExample : MonoBehaviour
{
	class Dummy
	{
		public float x, y, z;
	}
	
	Dummy[] pool = new Dummy[10000];
	
	void Start()
	{
		for (int i = 0; i < pool.Length; i++)
		    pool[i] = new Dummy();
	}
	
	void Update()
	{
		for (int i = 0; i < pool.Length; i++)
		{
		    var d = pool[i];
		    d.x = i;
		    d.y = i * 2;
		    d.z = Time.frameCount;
		}
	}
}

実行結果

object pool では、オブジェクトを事前に一度だけ生成し再利用する。

Update 内で新規生成を行わないため、実行中のヒープ確保が発生せず GC Alloc が 0 になる。


まとめ

  • Badサンプルのように毎フレームでnewを大量にするなど意図せぬところからCPU時間は最悪になりやすい。
  • BadからGoodのように工夫をすることで大幅にメモリ効率を上げることができる。
  • Good例両者の違い
    • struct は「データ量が小さく、寿命が短いもの」に向く。
    • object pool は「参照型を使う必要があるが、生成コストを抑えたい場合」に有効。
  • 今回の検証を通して、「C#は遅い」のではなく、「GCとメモリモデルを理解せずに書くと
    不安定になる」という点が最も重要だと感じた。

3. C++

サンプルコード

Example.cpp
#include <iostream>
#include <vector>
#include <chrono>

struct Data
{
    float x, y, z;
};

int main()
{
	const int count = 100000;   // データ数
	const int frames = 1000;    // フレーム数
	std::vector<Data> data(count); // Dataを10万個まとめて確保
	
	auto start = std::chrono::high_resolution_clock::now(); // 現在時刻を取得
	
	for (int f = 0; f < frames; ++f)
	{
	    for (auto& d : data) // dataの中身を1つずつ参照
		{
	        d.x += 0.1f;
	        d.y += 0.1f;
		    d.z += 0.1f;
		}
	}
	
	auto end = std::chrono::high_resolution_clock::now(); // 時間計測終了
	
	auto duration = std::chrono::duration_cast
		<std::chrono::milliseconds>(end - start);// 経過時間をミリ秒に変換
	
	std::cout << "Total time: " << duration.count() << " ms" << std::endl;
	std::cout << "Average per frame: "
		<< (double)duration.count() / frames << " ms" << std::endl;
	
	return 0;
}

実行結果

UnityでのCPU時間に一番近いのはAverage per frameの値であるが、
正しくはニアリーイコールとなる。
また、C++側ではフレーム数と処理量を固定し、純粋なCPU処理時間を測定したのに対し、
Unity側では実フレーム更新の中でGC挙動とフレーム時間の安定性を観測しているため、純粋な速度比較を行うことはできない。
それでもリリース版だと0.051msと60FPS(16.6ms)を目指す上でならもはや誤差と呼べるほど
高速であったことが確認できた。


4. では、C++が最強なのか?

以上を踏まえると、「Unity(C#)ではなくC++で作る方が常に正解なのでは」と考えがちだが、結論から言えばそうではない。

C++が高速なのは「GCが存在しないから」ではなく、正確には
メモリ配置と実行モデルを完全に制御できることに起因する。

GCとは、不要になったオブジェクトを自動的に検出・解放する仕組みであり、
プログラマーが明示的に解放処理を書かなくてもメモリ管理が行われるという点で、非常に安全で強力な機構である。

一方でこの仕組みは、
「いつ回収されるか」「どのタイミングで止まるか」を完全には制御できないため、
リアルタイム性が重要なゲームではフレーム時間の不安定要因になりやすい。

Unityではこのようにメモリ管理・型安全・例外処理など、多くの要素がランタイムにより自動管理されるため、

というメリットがある一方で、

といった「制御不能領域」を内包する。

対してC++では、

という特性により、
CPU・メモリ・キャッシュ挙動をすべて設計対象に含めることができる。

その代償として、

といったリスクもすべてプログラマー責任となり、
設計力と実装力が要求される世界になる。

つまり、

という思想の違いであり、
優劣ではなく 用途に応じたトレードオフ である。

5. Unity(C#)とC++の使い分け

Unity

向いているジャンル

・2Dゲーム
・脱出ゲーム
・パズル
・ノベル
・中〜小規模3D

Unityの特徴は主に以下の3つ

観点 理由
開発速度 圧倒的に速い
表現 エディタが強力
保守 安全で壊れにくい
人材 採用しやすい

C++

向いているジャンル

・大規模3D
・オープンワールド
・弾幕 / 群集
・物理 / AI
・コンソール(特にSwitch)
観点 理由
フレーム安定 完全制御
メモリ 自由配置
最適化 CPU直叩き
プラットフォーム 制限が厳しい

6. プラットフォーム別の判断例

プラットフォーム 判断
PC Unity C# で十分
PS / Xbox Unity + 最適化
Switch ネイティブ多め
スマホ GC厳禁 / Native併用
VR フレーム最優先

まとめ

観点 Unity (C#) C++
開発速度 非常に速い 遅い
メモリ管理 GC管理 完全手動
フレーム安定性 設計次第 高い
CPU最適化 制約あり 極めて自由
向いている用途 インディー / 中規模 AAA / エンジン
主な採用例 モバイル・2D・インディー コンシューマ・エンジン

Discussion