通常ありえないところで一日無駄にしたので備忘録。
実際、発生した問題とUnityとはあまり関係がありませんが、ゲームの開発中に同じ問題に直面した人のために残しておきます。恐らく私以外にはいないことを祈る。
2019/09/29追記:コメントにてすごく尤もな指摘を受けたので少し加筆修正。あと記事内で「イテレータ」という言葉がありますが、用いられる意味としてはどちらかというと「カウンタ変数」が近いです。
事の発端(本題とは関係ない)
弾幕シューティング作ろう!
環境はPC想定でまあとにかく作るか。
基本中の基本のプログラムは作ったぞ……性能にもこだわりたいから、いろいろ調べた結果Unity ECSとC# Job Systemsを使おう!
……と、その前に性能比較用に弾数をだんだん増やすスクリプトを作った! 早速計測だ!(Ctrl+P)
** 突 然 の 死**
Ctrl+Shift+ESCでタスクマネージャで見てみるとエディタが応答なし。そして時間経過でメモリ使用量が増えていく。このままではメモリ圧迫してろくでもないことになるのは明白。慌てず騒がず落ち着いてタスクを終了。
経過
Unity関連でかなり色々と調べはしたものの問題は判明せず。これはもう無視してECSとか導入した方が良いのでは? いやしかし問題を放置して新しい技術の勉強はちょっと気が引ける。どうしよう。
と、あるものを見つけた。
フリーズした画面。エディタ内のInspectorに弾の発射数を眺めるためにSerializeFieldにしていた変数が、フリーズするときは必ず「361」の数値になっているじゃ、あーりませんか!
しかし、「361」の数値は何を意味しているのか……。
ん、待てよ? 361ということは、360度、つまり弾の数に合わせた発射角の計算をイテレータ(i)に反映しているコードに何か問題が!
原因
というわけで調べました。
問題のコードがこちら
// メンバ変数
[SerializeField] private int bullet = 1;
/* ~~~~発射メソッド内~~~~ */
for (int i = 0; i < 360; i += (360 / bullet))
{
// Instantiate
}
bullet++;
C#プログラミング上級者の方に見せたら「そんなことでか」と言われそうなミスであります。
この中のfor文
for (int i = 0; i < 360; i += (360 / bullet))
に問題があります。
最初は複合代入演算子+=
か、イテレータの型int i
か、floatとintの比較<
に問題があるのかと考えていました。今思えば、3つ目に関しては二重に勘違いしていますね。
ということで順に勘違いを正していきましょう。
ちなみに360
のハードコーディングはテスト用のプラグラムなので問題ありません。
常識的正しい知識
この問題を詳しく調査して得た成果物。勘違いを粉微塵にする正しい知識集です。問題の解決法はこちらに。
整数同士の除算で得られる数値は必ず整数
3/2
、6/5
、3/4
はそれぞれ1
、1
、0
となります。
仮にfloat f = 3 / 2;
であっても、整数同士の除算による計算結果がすべて整数になるので、代入しようがしまいがf
の中身は1
になります。
そしてこの整数同士の除算の商は必ず小数点以下切り捨てとなります。これはマイナスの数値であっても同様です。
ちなみにこれが私の知らなかった一番の事実です。これを知らずに間違った知識で致命的なバグを引き起こしました。
floatとintの比較は特に問題ない
intはfloatに対して暗黙的型変換が可能なので、内部的にfloat同士で比較することが可能というわけです。
というかintとfloatの入り混じった式は特に問題ありません。
ただし、floatの値は必ずしも正確ではないことに注意。
floatとintの相互暗黙的型変換は成り立たない
一つ上の段落でintはfloatに暗黙的型変換が可能と述べましたが、対するfloatはdecimalに対してのみ暗黙的型変換が許されているのみで、float to intの変換は明示的、つまりキャストが必要です。
代入でいえば
float f = 3f / 2; // 1.5 (内部的にfloat同士で計算している)
int i = 3/2; // 1
f = i; // OK
i = f; // Error
i = (int)f; // OK
i = 3f / 2; // Error
メソッドでいえば
void Start()
{
A(2.4f); // Error
B(1); // OK
B(3/2); // OK
}
void A(int i) {}
void B(float f) {}
このような感じです。
まあこのあたりは直面した問題とさして関係ありません。
解説
改めてこのコードの何が悪いのかを見ていきます。
for(int i = 0; i < 360; i += (360 / bullet)) {}
まず、i += (360 / bullet)
の部分。
bullet
が361以上になると、整数同士の除算の法則に基づき0
が導き出されi
に加算されます。
必然と0
を足し続けることになるので、このfor文は無限ループと化します。
……だから応答なしになるし、内部的には際限なくInstantiateしていることになるのでメモリ使用量も増えるばかりなのです。
これを回避するためには、単に整数同士でなくすればよいだけです。ただしbullet
変数はintである必要があるので、360をfloat型にしましょう。
するとint型のi
にはfloat型を格納することになるので、i
もfloat型に変更しなければなりません。ぁちょっと待ったァ!
for文の初期化式は基本的にint型int i = 0;
でfloat型は使うべきではないと、わりと昔から言われているようです。現に私は少しだけ既視感を感じています。
for文にfloat型は望ましくないというなら、じゃあどうするのか教えてくれよキ○ベツ太郎と。
というかそもfor文の更新式で角度の計算も兼ねるとか横着すんじゃねえよと。
普通にfor文の基本形for(int i = 0; i < something; i++)
に沿えばいいだろう、というわけで。
for(int i = 0; i < bullet; i++)
{
float degree = 360f / i;
/* 角度をベクトルに変換する処理とかInstantiateとか */
}
このように変更することで無限ループを回避しました。
ちなみに360f
のハードコーディングはテスト用のプログラムなので問題ありません。(2回目)
……最初から横着しなければよかっただろうという至極尤もな意見は受け付けません。あしからず。
問題解決前の私の考えと結論
- 複合代入演算子に問題が
- ないです
- イテレータがint型なのが問題~~
そうだよfloatに変えろ-
ないです
- for文のカウンタ変数はint型(整数型)であるべし
イテレータを検索すると抽象化がどうのと出てくる……あれ?意味違う?C#のリファレンスには反復子とはあるけど、主にforeach……あっカウンタ変数……きちんと名称は調べるべきだな……ままええわ
- floatとintの比較に問題
- ないです
- もっと言えばfloatとintの比較ですらない
- 型変換たって宣言した変数の型そのものが変わるわけないだろ
このアホンダラ
そもそも論
for文内で除算をするのにイテレータにfloat型を使わないのはおかしい
そもfor文内にi += (360 / bullet)
という処理があり、角度の計算も兼ねるのでもちろんのこと整数以上の精度が必要なのにもかかわらず、int型のまま放置しているという開発者にはありえないミスとはいえfor文の初期化式にfloat型の変数は使うべきではない。float型の計算はあまり精度良くないってわりとどこでも言っているだろう!
そも特別な理由がない限りfor文の()内の処理で別の処理を兼ねたりする横着はしないようにする。(戒め)
いやまあ発射角度が360度以下のならまあ想定通りにはなるか?
その場合はまた別の実装をしないと無限ループは避けられないな。for文の(余計なことしてました(小声))()
内で余計なこと(横着)をしないとか。