C#におけるオブジェクトプールについて

フォーラム(掲示板)ルール
フォーラム(掲示板)ルールはこちら  ※コードを貼り付ける場合は [code][/code] で囲って下さい。詳しくはこちら
天空橋光
記事: 13
登録日時: 12年前

C#におけるオブジェクトプールについて

#1

投稿記事 by 天空橋光 » 12年前

はじめまして。
現在、C#(XNA)にてシューティングゲーム開発をしている天空橋光と申します。

シューティングゲームを作る際に、敵、弾、パーティクル、アイテムなどが
多量に出るゲームデザインを考えているため、絶えずnewを行うと
非常にパフォーマンスが悪い、またはGC発生率が高くなるため、
最初にこれらのオブジェクトを以下のようにプールすることにしました。

コード:

// プールを作成
void init() {
	for (int i = 0; i < 20000; i++) {
		GameObject o = new GameObject();
		pool.Push(o);
	}
}

// プールよりオブジェクトを取得
GameObject createGameObject() {
	return cache.Pop();
}

// 利用が終わったオブジェクトをプールに戻す
void deleteGameObject(GameObject o) {
	cache.Push(o);
}
このように管理することで生成時の負荷や、GC発生の問題は回避できたのですが、
問題が一つ発生しました。

ゲームが大きくなると、敵の種類、弾の種類などが増えGameObjectという一つのクラスでは
データを共通管理するのが困難になってきたのです。
例えば以下のようケースだと、座標や体力などは、ある程度共通に取り扱える。
しかし、ある敵は羽を持っているので、羽を広げるまでの時間を管理する必要があるとかいった場合、
その敵特有の変数を持つ必要がある。

コード:

class GameObject {
	////// 以下のものは十分共通として利用できる
	
	// 各種座標データ
	public float x;
	public float y;
	public float z;
	
	// 体力データ
	public hp;
	
	////// 以下のようなものは共通として利用することは無い
	
	// 羽を管理するための変数郡
	public int wingStartTime;
	public int wingCloseTime;
}
少しだけならいいのですが、これがゲームが大きくなると、GameObjectのメンバがものすごい勢いで増えていきます。
とりあえず、現在私が行った対応は以下のような、汎用変数を作ることで回避してみました。

コード:

class GameObject {
	////// 以下のものは十分共通として利用できる
	
	// 各種座標データ
	public float x;
	public float y;
	public float z;
	
	// 画面に登場してからの経過時間
	public time;
	
	////// 各固有のデータについては以下の変数を利用する
	
	// 汎用変数
	public int[] iVal = new int[10];
	public float[] fVal = new float[10];
}

// 実際に利用する場合は以下のように利用
void actionWingEnemy(GameObject o) {
	int wingStartTime = o.iVal[0];
	int wingEndTime = o.iVal[1];
	
	if (wingStartTime == time) {
		// 羽を広げる
	} else if (wingEndTime == time) {
		// 羽を閉じる
	}
}
このやり方はメモリにやさしいかもしれないのですが、非常にわかりずらいです。

そこで質問なのですが、C#でオブジェクトプールをどのように行うのが良いのか教えていただけないでしょうか?
または、そもそも、考え方かなにかがが間違っていたりするのでしょうか?
(例えばオブジェクトプールとかする必要ないよね?・・・とか)

なお、自分なりに考えた、他の対策には以下のようなものがあります。

他の案1.
敵ごとにWingGameObjectやNormalGameObjectのようにクラスを作製し、それごとにプールを作製する。

他の案1に対する個人的考察.
各ステージにおけるアイテム数などを個別に設定する必要があり非常に面倒

他の案2.
C++とかで、mallocで最初にメモリを一定量確保し、new演算子をオーバーロードして、確保したメモリからインスタンスを生成する
のと同等なことをC#でもしてみる。

他の案2に対する個人的考察.
C#だと、そもそも実現不可能?
最後に編集したユーザー 天空橋光 on 2012年2月14日(火) 16:20 [ 編集 1 回目 ]

アバター
bitter_fox
記事: 607
登録日時: 13年前
住所: 大阪府

Re: C#におけるオブジェクトプールについて

#2

投稿記事 by bitter_fox » 12年前

天空橋光 さんが書きました: そこで質問なのですが、C#でオブジェクトプールをどのように行うのが良いのか教えていただけないでしょうか?
または、そもそも、考え方かなにかがが間違っていたりするのでしょうか?
(例えばオブジェクトプールとかする必要ないよね?・・・とか)

なお、自分なりに考えた、他の対策には以下のようなものがあります。

他の案1.
敵ごとにWingGameObjectやNormalGameObjectのようにクラスを作製し、それごとにプールを作製する。

他の案1に対する個人的考察.
各ステージにおけるアイテム数などを個別に設定する必要があり非常に面倒

他の案2.
C++とかで、mallocで最初にメモリを一定量確保し、new演算子をオーバーロードして、確保したメモリからインスタンスを生成する
のと同等なことをC#でもしてみる。

他の案2に対する個人的考察.
C#だと、そもそも実現不可能?
オブジェクトプールを作る必要があるかどうかは置いておいて、案1に準ずるものですがジェネリクスを使用すればいかがでしょうか?

ちなみに汎用変数はバグの元になりうるのでやめるべきだと思います。

ところで命名規則がC#というよりもJavaに見えます。C#で書く以上は命名規則はC#の物に合わせたほうがいいと思いますよ。

天空橋光
記事: 13
登録日時: 12年前

Re: C#におけるオブジェクトプールについて

#3

投稿記事 by 天空橋光 » 12年前

ご回答ありがとうございます。

すいません、ジェネリックスを使うというのがどこで利用するのかピンときていません。
以下のようにプールをジェネリックとして管理するということでしょうか?

コード:

// プール ジェネリック
class Pool<Type> {
	private Stack<Type> Cache = new Stack<Type>();
	
	// 初期化 - メモリ確保
	void Init(int size) {
		for (int i = 0; i < size; i++) {
			Cache.Push(new Type());
		}
	}
	
	// プールよりオブジェクトを取得
	Type Create() {
		return Cache.Pop();
	}
	
	// 利用が終わったオブジェクトをプールに戻す
	void Delete(Type o) {
		Cache.Push(o);
	}
}

// 実際利用する場合
Pool<WingGameObject> WingPool = new Pool<WingGameObject>();
WingPool.Init(100);

WingGameObject Foo = WingPool.Create();
上記のような感じだとした場合、私が記載した、案1に対する考察「各ステージにおけるアイテム数などを個別に設定する必要があり非常に面倒」というのはしょうがないのでしょうか?

見当はずれのことを言っていたら申し訳ありません。


なお、汎用変数を使った上記のやり方で実は8年近くゲームコードを書いているのですが、バグの元にもなるし、製造も保守も大変なのでやめたくてたまりません。
それでも汎用変数を利用しているのは、この方法よりベストな回答が見つかっていないためなのです。

それと、命名規則につきましては真におっしゃるとおりです。
Javaを愛していた期間が長かったため、趣味でコーディング(一応仕事ではちゃんとやる)をすると、
どんな言語でもJavaの命名規則で記述する癖がついてしまっているので、ちゃんとしないとですね。

YuO
記事: 947
登録日時: 13年前
住所: 東京都世田谷区

Re: C#におけるオブジェクトプールについて

#4

投稿記事 by YuO » 12年前

まず,根本的なGCを潰す,という点について。

可能ならば構造体の使用も考えてみるとよいかもしれません。
使い方を間違えなければ,構造体はGCの対象ではないため,GCを引き起こすこともありません。
# 構造体の配列はクラス,しかも大きなクラスになることに注意。

まぁ,Xbox360とPC/Windows PhoneではGCの方式が異なるので,その差も考慮する必要がありますが……。

第6回 .NETアプリを軽快にするためのガベージ・コレクション講座 (Nyaruru氏の記事)なども参考になるかと思います。
# なお,ボクシングの問題は,boxing チェッカー - NyaRuRuの日記にあるツールで捜し出すこともできます。

天空橋光 さんが書きました:上記のような感じだとした場合、私が記載した、案1に対する考察「各ステージにおけるアイテム数などを個別に設定する必要があり非常に面倒」というのはしょうがないのでしょうか?
個別に設定といえども「設定対象ごとにステージ情報から値を読み取って設定」するだけであって,
「設定対象ごと,ステージごとに値を設定するコードを別々に書く」わけではないですよね。
ステージ情報をちゃんとひとまとまりにしておけば,一度書くだけであとは面倒なことはないと思いますが……。

天空橋光
記事: 13
登録日時: 12年前

Re: C#におけるオブジェクトプールについて

#5

投稿記事 by 天空橋光 » 12年前

ご回答ありがとうございます。

短期的な値の利用に限るなら構造体の利用も一考の余地ありですが、
大量のゲームオブジェクトの場合は、スタックメモリはあまり容量がないため、食いつぶす危険性があるり、難しい気がしています。

それと、今回の本題(プール)とは別になりますが、「BOXINGの問題」についてはありがとうございます!
確かに原理をよくよく考えればヒープを利用することになりますよね。
一応、自分でゲームを作る際はGCの発生回数をモニタリングするようにしているので、現状問題にはなるほど表面化してはいないのですが、抑えておいたほうが良さそうなポイントですね。
YuO さんが書きました:個別に設定といえども「設定対象ごとにステージ情報から値を読み取って設定」するだけであって,
「設定対象ごと,ステージごとに値を設定するコードを別々に書く」わけではないですよね。
ステージ情報をちゃんとひとまとまりにしておけば,一度書くだけであとは面倒なことはないと思いますが……。
実際作製しているシューティングゲームでは、GameObjectの種類は100種類を超えており、仕様追加、変更及び、ステージ構成の変更などを行う都度、100種類近い各データの個数を設定管理するのは、それはそれで結構しんどいと思っています。

また、ステージごとで利用するもの、しないものがあるので、できれば、メモリの効率化を考慮し、ステージごとに設定を行いたいと思うので、その辺も結構面倒かと思っています。

この方法が総合的にベストだとするなら、当然その手間を行うのですがw

アバター
bitter_fox
記事: 607
登録日時: 13年前
住所: 大阪府

Re: C#におけるオブジェクトプールについて

#6

投稿記事 by bitter_fox » 12年前

天空橋光 さんが書きました: すいません、ジェネリックスを使うというのがどこで利用するのかピンときていません。
以下のようにプールをジェネリックとして管理するということでしょうか?
そういう事です。
天空橋光 さんが書きました: 上記のような感じだとした場合、私が記載した、案1に対する考察「各ステージにおけるアイテム数などを個別に設定する必要があり非常に面倒」というのはしょうがないのでしょうか?
これはプールを自動的に拡張するようにすれば問題じゃなくなるのではないでしょうか?
それとも別の問題でしょうか?

あるいは、各タイプ毎にジェネリクスなオブジェクトが作られるのが面倒くさいという事であれば次のようにすれば一つにまとめることが出来ます。(自分はC#よりもJavaの方が明るいのでJavaで書きます。天空橋光さんもJavaには明るいようなのでエッセンスだけ読み取ってC#に落としてください。)

コード:

public class GameObjectPool
{
	private Map<Class<? extends GameObject>, Queue<? extends GameObject>> pools = new HashMap<Class<? extends GameObject>, Queue<? extends GameObject>>();

	@SuppressWarnings("unchecked")
	protected <T extends GameObject> void expand(Class<T> cls) throws ReflectiveOperationException
	{
		Queue<T> pool;

		if (pools.containsKey(cls))
		{
			pool = (Queue<T>)pools.get(cls);
		}
		else
		{
			pool = new LinkedList<T>();
			pools.put(cls, pool);
		}

		for (int i = 0; i < 20000; i++) // 2万個要素を作る。量は変えれるようにするべき。
		{
			T t = cls.getConstructor().newInstance();
			pool.offer(t);
		}
	}

	public <T extends GameObject> T popGameObject(Class<T> cls)  throws ReflectiveOperationException
	{
		if (!pools.containsKey(cls) || pools.get(cls).isEmpty())
		{
			this.expand(cls);
		}

		return cls.cast(pools.get(cls).poll());
	}

	@SuppressWarnings("unchecked")
	public <T extends GameObject> T pushGameObject(T t) throws ReflectiveOperationException
	{
		if (!pools.containsKey(t.getClass()))
		{
			this.expand(t.getClass());
		}

		((Queue<T>)pools.get(t.getClass())).offer(t);

		return null;
	}
}

コード:

GameObjectPool gop = new GameObjectPool();

Item item = gop.popGameObject(Item.class);
Bullet bullet = gop.popGameObject(Bullet.class);

item = gop.pushGameObject(item);
bullet = gop.pushGameObject(bullet);
C#だとJavaよりもジェネリクスが強力なので、もっとスマートに書けると思います。
天空橋光 さんが書きました: それと、命名規則につきましては真におっしゃるとおりです。
Javaを愛していた期間が長かったため、趣味でコーディング(一応仕事ではちゃんとやる)をすると、
どんな言語でもJavaの命名規則で記述する癖がついてしまっているので、ちゃんとしないとですね。
分かります、ついついなじみの深い言語の命名規則で書いちゃいますよね。

ISLe
記事: 2650
登録日時: 13年前
連絡を取る:

Re: C#におけるオブジェクトプールについて

#7

投稿記事 by ISLe » 12年前

EnemyA,EnemyB,EnemyCがそれぞれどれだけいつ発生していつ消滅するか分からないが同時に100匹まで発生する前提である。
Enemyを100個確保しておいた後、破棄・生成することなしにEnemyA,EnemyB,EnemyCに振り分けたい。
というような質問だと思いますけど。
つまり最初にEnemyAが100匹出て、消えて、次にEnemyBが100匹出るといったときにメモリの破棄も(余分な)確保も起きないようにしたいわけですよね。

ステージの移行とかシームレスにするなら必要ですよね。

C#は頑張れば共用体(らしきもの?)ができるらしいので汎用メンバを共用体にしてしまうと良いかもしれません。
Javaでガラケーアプリ作ってたときは、コンパイルの前にCのプリプロセッサを通すようにして、アクセッサマクロを使ってました。
汎用メンバのアクセッサを用意すれば保守しやすいと思いますけど。

天空橋光
記事: 13
登録日時: 12年前

Re: C#におけるオブジェクトプールについて

#8

投稿記事 by 天空橋光 » 12年前

bitter_foxさん、ISLeさん
ご回答ありがとうございます。

> bitter_foxさんへの返信

コードの例示までしていただいてありがとうございます。
bitter_fox さんが書きました: これはプールを自動的に拡張するようにすれば問題じゃなくなるのではないでしょうか?
それとも別の問題でしょうか?
自動的に拡張する部分が一番問題だと思っています。
ゲーム中のメモリ確保は限りなくゼロにしたいと思っています。
自動的に拡張とすると、GC発生を制御できなくなり、
GC発生による処理遅延が発生する危険性があるかと思います。

.Net Framework4になって、バックグラウンドGCが実装されたので、
PCに関していえば大分、無視してしまってもいいのかもしれないのですが、
それでも、GC発生がボトルネックになるのは変わらないため、極力なくしていきたいと思っています。


> ISLeさんへの返信
ISLe さんが書きました: EnemyA,EnemyB,EnemyCがそれぞれどれだけいつ発生していつ消滅するか分からないが同時に100匹まで発生する前提である。
Enemyを100個確保しておいた後、破棄・生成することなしにEnemyA,EnemyB,EnemyCに振り分けたい。
というような質問だと思いますけど。
つまり最初にEnemyAが100匹出て、消えて、次にEnemyBが100匹出るといったときにメモリの破棄も(余分な)確保も起きないようにしたいわけですよね。

ステージの移行とかシームレスにするなら必要ですよね。
ありがとうございます!
そのとおりです!
まさにそれが、現在実装しているコードの基本理念になっています。
ISLe さんが書きました: C#は頑張れば共用体(らしきもの?)ができるらしいので汎用メンバを共用体にしてしまうと良いかもしれません。
なんと?!共用体(らしきもの?!)ができるのですか!
それは情報ありがとうございます。
ちょっと調べてみたいと思います。
ISLe さんが書きました: Javaでガラケーアプリ作ってたときは、コンパイルの前にCのプリプロセッサを通すようにして、アクセッサマクロを使ってました。
汎用メンバのアクセッサを用意すれば保守しやすいと思いますけど。
なるほど!これは発想になかったです!
この方法は実現方法は想像つくので、実現候補として考えたいと思います!

YuO
記事: 947
登録日時: 13年前
住所: 東京都世田谷区

Re: C#におけるオブジェクトプールについて

#9

投稿記事 by YuO » 12年前

うーん,T4テンプレートが使えれば解決するのかな……。

天空橋光
記事: 13
登録日時: 12年前

Re: C#におけるオブジェクトプールについて

#10

投稿記事 by 天空橋光 » 12年前

> YuOさん

おぉ、こんなのもあるのですね!
ちょっと、読んでみたのですが、自前でプリプロセッサ作るより便利そうです!


・・・というわけで、
皆様色々とご回答いただきありがとうございました。

とりあえず、今回は処理への影響を最小限にしつつ、
コードの保守性を極力保つという方向性のもと、以下のような方針でプール処理を行うことに決めました。

1.C#で共用体を利用

これによって、無駄なオブジェクトの生成&削除と、メモリ利用をを最小限にとどめます。

2.共用体はクラスのメンバとして保持し、クラスをゲーム開始時に大量に生成し管理。以後生成は行わない。

ゲーム開始時に全てメモリ確保を行い、以後ヒープの増減をなくして、GC発生を抑えます。
また、クラスのメンバとして共用体を持つのは、単純にヒープメモリに配置させるためです。
スタックに大量のオブジェクトを配置するのは、やっぱり問題かなと。

3.共用体の生成はプリプロセッサを用いて、コードの保守性を若干高める

C#の共用体宣言は非常に面倒なため、Cのように記述を行い、プリプロセッサを自作してC#ように書き換えます。
ここで、T4テンプレートが利用できる気がしますが、出来なかったら普通に自作しますw
この対応を行うことで、VisualStudioなどのコードアシストが利用できなくなる可能性が高いですが、
僕はもともとコードアシストを使わない変態だし、個人開発なので、あまり問題がないため見切りますw


というわけで、だいぶ変態的な対応になった気がしますが、当面はこれでいこうと思います。
皆様本当にありがとうございました!

閉鎖

“C言語何でも質問掲示板” へ戻る