if (a + b == 0.3)と書いたのに、この条件は、通りません。ところが、同じ書き方をfloatでやると、今度は通ってしまいます。しかも、それが「正しく動いている」わけではないことが、この記事のいちばんの落とし穴です。
浮動小数点の誤差は、「小数の計算は、少しずれる」という説明だけだと、どれくらいずれて、どこで困るのかが見えません。そこで、doubleとfloatで、同じ計算を実際に動かして、ずれの大きさと、困る場面を測りました。ゲームで使う、経過時間の足し込みと、到着判定も確かめています。
検証は、Visual Studio 2022付属のMSVC(19.44、x64)を、Release(/O2 /MD)で動かして行いました。
浮動小数点の誤差とは?
浮動小数点の誤差とは、
・0.1のような小数を、2進数では、ぴったりとは表せないため、いちばん近い値で代用していることから生じるズレ
です。10進数の1/3が、0.333…と、割り切れないのと同じように、0.1も、2進数では割り切れません。float(約7桁)やdouble(約16桁)に収めるときに、丸められます。丸めた値どうしを足したり比べたりすると、結果が、期待とわずかにずれます。
実験①:0.1 + 0.2 と 0.3 を比べる
▼e1_basic.cpp
#include <cstdio>
int main()
{
volatile double da = 0.1, db = 0.2, dc = 0.3; // 定数畳み込みを避けるため、volatileで読む
volatile float fa = 0.1f, fb = 0.2f, fc = 0.3f;
double ds = da + db;
float fs = fa + fb;
std::printf("[double] 0.1 + 0.2 = %.17g\n", ds);
std::printf("[double] 0.3 = %.17g\n", (double)dc);
std::printf("[double] 0.1 + 0.2 == 0.3 ? %s\n", ds == dc ? "true" : "false");
std::printf("[float ] 0.1f + 0.2f = %.9g\n", (double)fs);
std::printf("[float ] 0.3f = %.9g\n", (double)fc);
std::printf("[float ] 0.1f + 0.2f == 0.3f ? %s\n", fs == fc ? "true" : "false");
return 0;
}[double] 0.1 + 0.2 = 0.30000000000000004
[double] 0.3 = 0.29999999999999999
[double] 0.1 + 0.2 == 0.3 ? false
[float ] 0.1f + 0.2f = 0.300000012
[float ] 0.3f = 0.300000012
[float ] 0.1f + 0.2f == 0.3f ? truedoubleでは、0.1 + 0.2が0.30000000000000004になり、0.3(実際には0.29999999999999999)と、一致しません。==は、falseです。
ところが、floatでは、0.1f + 0.2fも0.3fも、同じ0.300000012になって、==がtrueになりました。丸めの方向が、たまたまそろっただけです。「floatでは動いたから、問題ない」と判断すると、値を変えたとたんに、壊れます。
実験②:0.1を10回足しても、1.0にならない
▼e2_loop.cpp
#include <cstdio>
int main()
{
// 0.1を10回足して、ちょうど1.0になるかを、1回ずつ表示する
std::printf("[float]\n");
volatile float x = 0.0f;
for (int i = 1; i <= 10; ++i)
{
x = x + 0.1f;
std::printf(" %2d回目: x = %.9g%s\n", i, (double)x, x == 1.0f ? " (== 1.0f)" : "");
}
std::printf(" 10回足したあと x == 1.0f ? %s\n", x == 1.0f ? "true" : "false");
std::printf("[double]\n");
volatile double y = 0.0;
for (int i = 1; i <= 10; ++i)
{
y = y + 0.1;
}
std::printf(" 10回足したあと y = %.17g\n", (double)y);
std::printf(" y == 1.0 ? %s\n", y == 1.0 ? "true" : "false");
// 「xが1.0fになるまで回す」ループは、途中で追い越してしまう
int count = 0;
volatile float z = 0.0f;
while (z != 1.0f && count < 20)
{
z = z + 0.1f;
++count;
}
std::printf("[float] 'z != 1.0f の間回す' を20回で打ち切ったとき: count = %d, z = %.9g\n", count, (double)z);
return 0;
}[float]
1回目: x = 0.100000001
2回目: x = 0.200000003
3回目: x = 0.300000012
4回目: x = 0.400000006
5回目: x = 0.5
6回目: x = 0.600000024
7回目: x = 0.700000048
8回目: x = 0.800000072
9回目: x = 0.900000095
10回目: x = 1.00000012
10回足したあと x == 1.0f ? false
[double]
10回足したあと y = 0.99999999999999989
y == 1.0 ? false
[float] 'z != 1.0f の間回す' を20回で打ち切ったとき: count = 20, z = 2.00000024floatで0.1fを、10回足した結果は、1.00000012でした。doubleでも、0.99999999999999989で、どちらも、ちょうど1.0にはなりません。5回目に0.5ぴったりになるのは、0.5が2進数で割り切れる値だからで、0.1の足し算が正確なわけではありません。
いちばん危ないのは、最後のループです。z != 1.0fの間、回し続けるつもりで書くと、1.0fにちょうど当たることがなく、追い越して、止まりません。20回で打ち切ったときには、zは2.00000024まで進んでいました。
実験③:ゴールに着いたかを、==で判定する
ゲームでは、移動量を足し込んで、ゴールに着いたかを調べる場面があります。秒速3.3で、ゴール5.0へ向かうキャラクターで、判定の書き方を3通り比べました。
▼e6_goal.cpp
#include <algorithm>
#include <cmath>
#include <cstdio>
int main()
{
const float goal = 5.0f;
const float speed = 3.3f; // 秒速
const float dt = 1.0f / 60.0f;
// ① == で到着判定する
{
volatile float pos = 0.0f;
int frame = 0;
const int limit = 600; // 10秒ぶんで打ち切り
while (pos != goal && frame < limit)
{
pos = pos + speed * dt;
++frame;
}
std::printf("[== で判定] %d フレーム後: pos = %.7f, 到着判定 = %s\n", frame, (double)pos, pos == goal ? "true" : "false");
}
// ② >= で到着判定して、行きすぎたぶんは、ゴールに合わせる
{
volatile float pos = 0.0f;
int frame = 0;
while (pos < goal && frame < 600)
{
pos = pos + speed * dt;
++frame;
}
float overshoot = pos - goal;
std::printf("[>= で判定] %d フレーム後: pos = %.7f (行きすぎ %.7f)\n", frame, (double)pos, (double)overshoot);
pos = goal; // 到着したら、位置をゴールにそろえる
}
// ③ 1フレームで動ける量を、残りの距離で制限する(MoveTowards)
{
float pos = 0.0f;
int frame = 0;
while (pos != goal && frame < 600)
{
float step = speed * dt;
float remain = goal - pos;
pos = pos + std::min(step, remain); // 残りより大きく動かない
++frame;
}
std::printf("[MoveTowards] %d フレーム後: pos = %.7f, 到着判定 = %s\n", frame, (double)pos, pos == goal ? "true" : "false");
}
return 0;
}[== で判定] 600 フレーム後: pos = 33.0001259, 到着判定 = false
[>= で判定] 91 フレーム後: pos = 5.0049982 (行きすぎ 0.0049982)
[MoveTowards] 91 フレーム後: pos = 5.0000000, 到着判定 = true| 判定の書き方 | 到着までのフレーム | 結果 |
|---|---|---|
pos != goalの間、動く | 600フレームで打ち切り | ゴールを通り過ぎて、33.0まで進んだ(到着判定は偽のまま) |
pos < goalの間、動く | 91フレーム | 到着したが、5.0049982と、行きすぎた |
| 残りの距離で、1回の移動量を制限(MoveTowards) | 91フレーム | ちょうど5.0で止まった |
==での到着判定は、一度も成立せず、キャラクターは、ゴールを通り過ぎ続けました。>=(または<で回す)なら、到着は判定できますが、少し行きすぎます。1回に動ける量を、残りの距離までに制限する方法なら、ゴールの位置にぴったり止まり、==でも、判定できました。
誤差の比べ方:絶対誤差と相対誤差
==が危ないなら、「十分近ければ、同じ」と判定します。これが、イプシロン(許容する誤差)を使った比較です。大きく分けて、2通りあります。
- 絶対誤差:
fabs(a - b) <= eps。差の大きさそのものを、見る - 相対誤差:
fabs(a - b) <= eps * max(|a|, |b|)。値の大きさに対する、差の割合を見る
▼e3_compare.cpp
#include <cfloat>
#include <cmath>
#include <cstdio>
#include <algorithm>
// ① 絶対誤差で比べる
static bool NearAbs(double a, double b, double eps = 1e-9)
{
return std::fabs(a - b) <= eps;
}
// ② 相対誤差で比べる(大きい方の大きさに対する割合)
static bool NearRel(double a, double b, double eps = 1e-9)
{
double scale = std::max(std::fabs(a), std::fabs(b));
return std::fabs(a - b) <= eps * scale;
}
// ③ 絶対誤差と相対誤差の合わせ技(0付近は絶対、大きい値は相対)
static bool NearBoth(double a, double b, double absEps = 1e-12, double relEps = 1e-9)
{
double diff = std::fabs(a - b);
if (diff <= absEps) return true;
return diff <= relEps * std::max(std::fabs(a), std::fabs(b));
}
struct Case
{
const char* label;
double a, b;
const char* truth; // 人間が「同じとみなしたいか」
};
int main()
{
volatile double s = 0.1;
volatile double big = 1.0e10;
Case cases[] = {
{ "0.1+0.2 と 0.3", s + 0.2, 0.3, "同じ" },
{ "1e10+0.1+0.2 と 1e10+0.3", big + s + 0.2, big + 0.3, "同じ" },
{ "1e-12 と 2e-12(2倍違う)", 1.0e-12, 2.0e-12, "違う" },
{ "1e10 と 1e10+1(相対 1e-10)", 1.0e10, 1.0e10 + 1.0, "同じ" },
{ "1.0 と 1.001(0.1%違う)", 1.0, 1.001, "違う" },
};
std::printf("%-34s %-6s %-8s %-8s %-8s\n", "ケース", "本来", "絶対誤差", "相対誤差", "合わせ技");
for (const Case& c : cases)
{
std::printf("%-34s %-6s %-8s %-8s %-8s\n", c.label, c.truth,
NearAbs(c.a, c.b) ? "同じ" : "違う",
NearRel(c.a, c.b) ? "同じ" : "違う",
NearBoth(c.a, c.b) ? "同じ" : "違う");
}
std::printf("\nFLT_EPSILON = %g, DBL_EPSILON = %g\n", (double)FLT_EPSILON, DBL_EPSILON);
return 0;
}5つのケースで、3通りの判定(絶対誤差・相対誤差・両方を組み合わせたもの)を比べました。「本来、同じとみなしたいか」は、人間が決めた期待値です。
| ケース | 本来 | 絶対誤差 | 相対誤差 | 合わせ技 |
|---|---|---|---|---|
0.1+0.2と0.3 | 同じ | 同じ | 同じ | 同じ |
1e10+0.1+0.2と1e10+0.3 | 同じ | 違う | 同じ | 同じ |
1e-12と2e-12(2倍違う) | 違う | 同じ | 違う | 同じ |
1e10と1e10+1(相対で1e-10) | 同じ | 違う | 同じ | 同じ |
1.0と1.001(0.1%違う) | 違う | 違う | 違う | 違う |
【重要】イプシロンは、値の大きさに合わせないと、別の穴が開く
表を見ると、絶対誤差だけでは、2通りの失敗が出ています。
- 値が大きいとき(
1e10付近):誤差も大きくなるので、同じとみなしたい値を、「違う」と判定してしまう - 値が小さいとき(
1e-12付近):2倍違う値を、「同じ」と判定してしまう
相対誤差なら、この2つは、期待どおりになりました。ところが、「絶対と相対の合わせ技」にしても、3行目の1e-12と2e-12は、「同じ」と判定されました。絶対側の許容を1e-12にしていたため、差が1e-12ぴったりの値は、絶対側で通ってしまったのです。イプシロンは、「このくらい違っても、同じにしたい」という、扱っている値の大きさに合わせて、決める必要があります。
なお、floatの機械イプシロン(1.0の次に大きい値との差)は、FLT_EPSILON = 1.19209e-07、doubleでは、DBL_EPSILON = 2.22045e-16でした。これは、「1.0付近」での目安で、値が大きくなるほど、隣り合う値の間隔も広がります。FLT_EPSILONを、そのまま、あらゆる値の許容誤差にするのは、避けてください。
時間の足し込みは、floatだと、ずれる
次に、ゲームの経過時間を、floatに、1/60秒ずつ足し込む場面です。60FPSで、1時間ぶん(216000フレーム)を、足しました。doubleとの比較も載せています。
▼e4_accum.cpp
#include <cstdio>
int main()
{
// ① ゲームの経過時間を、1/60秒ずつfloatで足していく(60FPSで1時間ぶん = 216000フレーム)
volatile float tf = 0.0f;
volatile double td = 0.0;
const int frames = 60 * 60 * 60;
for (int i = 0; i < frames; ++i)
{
tf = tf + 1.0f / 60.0f;
td = td + 1.0 / 60.0;
}
std::printf("60FPSで1時間(%d フレーム)ぶん足した結果 (正解は 3600 秒)\n", frames);
std::printf(" float : %.6f 秒 (誤差 %.6f 秒)\n", (double)tf, (double)tf - 3600.0);
std::printf(" double: %.6f 秒 (誤差 %.6f 秒)\n", (double)td, (double)td - 3600.0);
// ② floatの整数カウンターは、2^24(16777216)で止まる
volatile float counter = 16777210.0f;
for (int i = 0; i < 12; ++i)
{
counter = counter + 1.0f;
std::printf(" counter + 1 -> %.1f\n", (double)counter);
}
return 0;
}60FPSで1時間(216000 フレーム)ぶん足した結果 (正解は 3600 秒)
float : 3597.206787 秒 (誤差 -2.793213 秒)
double: 3600.000000 秒 (誤差 0.000000 秒)
counter + 1 -> 16777211.0
counter + 1 -> 16777212.0
counter + 1 -> 16777213.0
counter + 1 -> 16777214.0
counter + 1 -> 16777215.0
counter + 1 -> 16777216.0
counter + 1 -> 16777216.0
counter + 1 -> 16777216.0
counter + 1 -> 16777216.0
counter + 1 -> 16777216.0
counter + 1 -> 16777216.0
counter + 1 -> 16777216.0doubleは、ぴったり3600.000000秒でした。一方、floatは3597.206787秒で、約2.8秒、足りませんでした。5分ごとの誤差を、記録したのが、次のグラフです。

経過(分) float合計(秒) 誤差(秒)
5 299.9709 -0.0291
10 599.8976 -0.1024
15 899.8244 -0.1756
20 1200.3949 0.3949
25 1501.4203 1.4203
30 1802.4457 2.4457
35 2103.0662 3.0662
40 2401.8943 1.8943
45 2700.7224 0.7224
50 2999.5505 -0.4495
55 3298.3787 -1.6213
60 3597.2068 -2.7932ここでも、floatの大きな値に、小さな値を足すとき、足す側の下の桁が、丸められて捨てられていきます。合計が大きくなるほど、1/60の端数が、うまく入りきらなくなる、ということです。
実験の後半は、floatの整数カウンターです。16777210.0fから、1.0fずつ足していきました。
16777216.0(2の24乗)に達したところで、足し算が、止まりました。floatの仮数部は、24ビットぶんの精度しかなく、16777216.0f + 1.0fは、16777216.0fに、丸められてしまうからです。フレーム数やスコアを、floatで数えると、ある時点から、増えなくなります。
直し方:整数で数えて、必要なときに割る
「足し込むと、誤差が積もる」なら、足し込まないのが、いちばん確実です。時間は、フレーム数(整数)で数えて、必要なときに、割ります。金額なら、円の小数ではなく、最小単位(銭やセント)の整数で持ちます。
▼e5_int.cpp
#include <cstdio>
int main()
{
// ① 小数にしたくないものは、最小単位の整数で持つ(0.10, 0.20, 0.30 を、1/100単位の10, 20, 30にする)
int priceA = 10;
int priceB = 20;
int total = 30;
std::printf("整数(1/100単位): %d + %d == %d ? %s\n", priceA, priceB, total,
priceA + priceB == total ? "true" : "false");
// ② 時間を足し込まず、フレーム数を整数で数えて、必要なときに割る
long long frames = 0;
for (int i = 0; i < 60 * 60 * 60; ++i)
{
++frames;
}
double seconds = (double)frames / 60.0;
std::printf("フレーム数 %lld を 60 で割った時間: %.6f 秒 (正解は 3600 秒)\n", frames, seconds);
return 0;
}整数(1/100単位): 10 + 20 == 30 ? true
フレーム数 216000 を 60 で割った時間: 3600.000000 秒 (正解は 3600 秒)整数なら、10 + 20 == 30は、いつでもtrueです。フレーム数216000を60で割った時間も、ぴったり3600秒になりました。
floatの比較で、よくある失敗例と対処法
①「floatでは、==で動いた」と、そのままにする
実験①のとおり、floatで一致するのは、丸めが、たまたまそろっただけです。計算の順番や、値を変えると、すぐに崩れます。==は、整数とフラグ以外では、使わないのが安全です。
②イプシロンに、決め打ちの値を使う
1e-5のように、固定の値をコピペして使うと、値が大きい場面でも、小さい場面でも、判定がずれます。値の大きさに合わせるか、相対誤差を使います。
③「ちょうど到着」を、位置の==で判定する
実験③のとおり、位置を足し込む移動では、ゴールの座標に、ぴったり一致しません。距離で判定するか、残りの距離で、移動量を制限します。
④経過時間や、スコアを、floatで足し続ける
1時間で約2.8秒ずれ、2の24乗で止まります。長く動くゲームでは、時間はdoubleか、整数のフレーム数で持ちます。deltaTimeの記事で扱っている、フレーム間の差分(deltaTime)そのものを、floatで使うこと自体は問題ありません。問題になるのは、それを足し込んで、長期間の合計に使うときです。
注意点
0.1のような小数は、2進数で割り切れないので、近い値で代用されているdoubleでは0.1+0.2==0.3が偽だが、floatでは、たまたま真になった(今回の環境)0.1を10回足した結果は、floatで1.00000012、doubleで0.99999999999999989。!= 1.0fで回すと、止まらない- 到着判定は、
==だと成立しなかった。残りの距離で、移動量を制限すると、ちょうど止まった - 絶対誤差だけでは、大きい値で「違う」、小さい値で「同じ」と、両方向に間違えた。イプシロンは、値の大きさに合わせる
floatに1/60を足し込むと、1時間で約-2.8秒。floatの整数カウンターは、16777216で止まる- 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、
/O2 /MD)で行った。丸めの結果は、コンパイラや設定で変わりうる
この記事のポイント
- 浮動小数点の
==は、たまたま通ることがあるので、通ったことが、安全の証拠にならない - 比べるなら、イプシロンを使う。ただし、値の大きさに合わせた許容誤差を選ぶ
- 足し込む処理は、誤差が積もる。時間はフレーム数(整数)、金額は最小単位の整数にして、必要なときに割る
- 到着判定は、位置の一致ではなく、残りの距離で、移動を止める形にする
小数の計算で「少しずれる」ことは、避けられません。避けられないものは、ずれないふりをせずに、ずれてもよい形に、書き直す。それが、==をやめる、本当の理由でした。

