はじめに
巨大な値を使った際にグラデーションの諧調が落ちるという現象に遭遇したので、まとめたいと思います。
本記事は、簡潔さを重視した内容となっているため、情報の正確さに欠ける面があるかもしれません。
環境
Unity 2020.2.0f1
Universal RP 10.2.2
事の発端
シェーダーグラフにて、巨大な数を使うとグラデーションの諧調が減るという不可解な現象に遭遇しました。
今回はfloat(浮動小数点数)の視点から、階調が減る現象について検証していきたいと思います。
検証ではShaderLabを使用します。
検証 その1 : UV.xを画面に出す
ShaderLabでUVのxだけを返すようなフラグメントシェーダーを書いてみます。
fixed4 frag (v2f i) : SV_Target
{
return i.uv.x;
}
結果
検証 その2 : UV.xに巨大な整数を足す
次に、0.001
という小数を足して、frac
で小数部分だけを返すようにしてみます。
fixed4 frag (v2f i) : SV_Target
{
return frac(i.uv.x + 0.001);
}
結果
同じく滑らかなグラデーションが表示されます。
ここまでは、問題ないかと思います。
検証 その3 : 10^6を足してみる
10^6
という巨大な数を足してから、frac
で小数部分だけを返すようにしてみます。
fixed4 frag (v2f i) : SV_Target
{
return frac(i.uv.x + 1e6 + 0.001);
}
結果
整数を足しているだけだから、滑らかなグラデーションが表示されるのでは?と思ってしまいそうですが、
結果は以下のようなカクカクしたグラデーションになります。

この現象は、float(浮動小数点数) が関係していると考えられます。
floatの内部表現
シェーダーのfloat は 32bit float(単精度浮動小数点数) というもので、
0.3や0.7といった実数を32ケタの2進数で表現します。
ビット列から実数を計算する
符号部を $sign$ (0か1) 、指数部を $e$ (0 ~ 255の整数)、仮数部のビット列を $b_1, b_2, b_3, ... , b_{23} $ と置きます。
この時、floatが表現する実数 $value$ は以下の計算式で求めることができます。(IEEE754規格)
value = (-1)^{sign} \cdot (1 + \sum_{i=1}^{23}b_{i}2^{-i} ) \cdot 2^{e - 127}
クイズ
以下のようなビット列が表す実数 $value$ はどんな値になるでしょうか?
答え
符号部 : $sign = 0$
指数部 : 8桁目のビット列が1になっているので、$e = 2^7 = 128$
仮数部のビット列 : $b_1 = 1, b_4 = 1, b_5 = 1$
ビット列が表す値$value$は以下のようになります。
\begin{align}
value &= (-1)^0 \cdot (1 + 2^{-1} + 2^{-4} + 2^{-5}) \cdot 2^{128- 127} \\
\\
&=
(1 + 0.5 + 0.0625 + 0.03125) \cdot 2
\\
\\
&= 3.1875
\end{align}
本題
さて、ここからが本題です。
以下のようなフラグメントシェーダーを書いた場合、グラデーションがカクカクしてしまいます。
float4 frag (v2f i) : SV_Target
{
return frac(i.uv.x + 1e6 + 0.001);
}
カクカクしてしまうのは、i.uv.x + 1e6 + 0.001
という計算に原因があります。
桁数が大きく異なる数を足し合わせると、小さい方の数の情報が落ちてしまうためです。
1e6のビット列
i.uv.x + 1e6 の計算
次に、i.uv.x + 1e6
を計算します。
uv.x = 0.3の場合
i.uv.x = 0.3
の時、ビット列は以下のようになります。
そして、0.3 + 10^6
のビット列は以下のようになります。
10^6
の仮数はそのままですが、0.3
の仮数はだいぶ下の方にズレてしまいました。
0.3
のビット列の大部分が欠損しています。
このような現象は情報落ちと呼ばれます。
uv.x = 0.9の場合
i.uv.x = 0.9
の場合を考えてみます。
0.9 + 10^6
のビット列は以下のようになります。
仮数の下4ケタを0.9
の仮数(だったもの)が埋めています。
uv.x = 1.0 の場合
i.uv.x = 1.0
の場合を考えてみます。
1.0 + 10^6
のビット列は以下のようになります。
仮数の下5ケタを1.0
の仮数(だったもの)が埋めています。
i.uv.x + 1e6 のUVデータ制度
i.uv.x + 1e6
を計算した場合、グラデーションの表現に使える領域は 4 ~ 5ビット程度になりそうです。
カクカクしたグラデーションを見てみると、グラデーションの諧調は16段階になっていますね
おまけ : さらに大きい数を足してみる
これまでは1e6を足していましたが、2倍の2e6を足してみます。
float4 frag (v2f i) : SV_Target
{
return frac(i.uv.x + 2e6 + 0.001);
}
結果
諧調数が半分の8
になります。

おまけ2 : 0.001を外す
0.001を外して以下のようなフラグメントシェーダーを書くと、グラデーションがなめらかになります。
float4 frag (v2f i) : SV_Target
{
return frac(i.uv.x + 2e6);
}

知識不足で理由がよくわからないのですが、シェーダーコンパイラが良い感じに最適化してくれているのかもしれません。
関連
【Unity】【シェーダ】小数点の精度と型の使い分けについて(float / half / fixedの話)
https://light11.hatenadiary.com/entry/2018/06/01/001008
地味にヤバい、シェーダ変数の精度について
https://techblog.kayac.com/unity-shader-parameters-precision