今回は NalulunaCounters で処理している pp の計算処理について書いてみます。
ScoreSaber の pp は、譜面の星 (stars) と、譜面クリア時の精度から決定されます。精度というのは、0 ~ 1 までの浮動小数点数、あるいはパーセントで表されますが、これは理論上の最高点 (フルコンボしたとき) に対しての実際の点数の割合を表します。計算的には簡単に「精度 = 実際の点数 ÷ 理論上の最高点」で、この記事ではパーセントではなく 0 ~ 1 までの値で扱います。
余談ですが、理論上の最高点の計算を自力でやるのはすこし面倒です。単純に 115 かけるノーツ数では決まりません。ノーツを連続で切ることにより上昇するコンボゲージによって、最大 8 倍までの倍率がつくので、コンボゲージが上昇しきるまでの 8 倍以外になるノーツの点数を厳密に計算する必要があります。また 115 点にならないアークやチェインも増えたので、計算はさらに複雑になっています。
精度と得られるppの倍率をグラフにすると下記のようになります。ここでは横軸を精度、縦軸を倍率になっています。(ScoreSaber discord の curve-discussion より引用)
これを一発で算出する数学的な関数があるわけではないようで、その代わり、特定の精度ごとでの pp に対する倍率が公開されています。例えば、2023年5月上旬時点の設定は下記のようになっています。左の数字が精度で、それに対応する倍率が右の数字、これをグラフにプロットすると上図のようになるわけですね。
----
let DuhhelRamen= {
name: 'DuhhelRamen V5:tm:',
points: [
[1, 7],
[0.999, 5.8],
[0.9975, 4.7],
[0.995, 3.76],
[0.9925, 3.17],
[0.99, 2.73],
[0.9875, 2.38],
[0.985, 2.1],
[0.9825, 1.88],
[0.98, 1.71],
[0.9775, 1.57],
[0.975, 1.45],
[0.9725, 1.37],
[0.97, 1.31],
[0.965, 1.20],
[0.96, 1.11],
[0.955, 1.045],
[0.95, 1],
[0.94, 0.94],
[0.93, 0.885],
[0.92, 0.835],
[0.91, 0.79],
[0.9, 0.75],
[0.875, 0.655],
[0.85, 0.57],
[0.825, 0.51],
[0.8, 0.47],
[0.75, 0.40],
[0.7, 0.34],
[0.65, 0.29],
[0.6, 0.25],
[0.0, 0.0],
],
};
----
上のデータを見るとわかる通り、精度の数字は飛び飛びになっており、その間の精度になった場合の倍率は、補完で求める必要があります。
簡単に考えると、データがない部分は直線でつながっていると考えてしまうのが早いです。たとえば精度が 0.625 の倍率を求めるには、データ列を見て、精度が 0.6 と 0.65 の中間なので、倍率も 0.25 と 0.29 の中間として、計算結果は 0.27、という具合です。
さて、試しにこれで計算してみると、実際に ScoreSaber で見られる結果と、僅かに異なる数字になってしまいます。データがない部分も曲線でつながっているのでしょうか。そこで曲線を補完できる方法を考えます。
Unity には、このようなポイントごとのデータをなめらかな曲線に補完できる仕組みが存在します。それはなにかというと…。
AnimationCurve。AnimationClip に設定すると、このようにカーブのグラフを目で確認することもできます。下記のようなコードで、精度を time に、倍率を transform に入れるようにして、AnimationClip として出力しました。
----
[MenuItem("Test/Test1", false, 1)]
static void Test1()
{
var data = new (float x, float y)[]
{
(0.0f, 0.0f),
(0.6f, 0.25f),
(0.65f, 0.29f),
(0.7f, 0.34f),
(0.75f, 0.4f),
(0.8f, 0.47f),
(0.825f, 0.51f),
(0.85f, 0.57f),
(0.875f, 0.655f),
(0.9f, 0.75f),
(0.91f, 0.79f),
(0.92f, 0.835f),
(0.93f, 0.885f),
(0.94f, 0.94f),
(0.95f, 1f),
(0.955f, 1.05f),
(0.96f, 1.115f),
(0.965f, 1.195f),
(0.97f, 1.3f),
(0.9725f, 1.36f),
(0.975f, 1.43f),
(0.9775f, 1.515f),
(0.98f, 1.625f),
(0.9825f, 1.775f),
(0.985f, 2.0f),
(0.9875f, 2.31f),
(0.99f, 2.73f),
(0.9925f, 3.31f),
(0.995f, 4.14f),
(0.9975f, 5.31f),
(0.999f, 6.24f),
(1f, 7)
};
var curve = new AnimationCurve();
foreach (var d in data)
{
curve.AddKey(new Keyframe(d.x, d.y));
}
var clip = new AnimationClip();
clip.SetCurve("", typeof(Transform), "y", curve);
AssetDatabase.CreateAsset(clip, AssetDatabase.GenerateUniqueAssetPath("Assets/test.anim"));
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
----
ただ、これだけでは問題がありました。下のグラフは、縦軸を拡大した様子です。
ポイントのつなぎ目の部分が段々になってしまっていますね。これは上のグラフ内のハンドル (マウスで掴むことができる棒) に見えるように、ポイントごとに設定できるベジェ曲線の曲げ具合がすべて水平になってしまっているせいです。そこでこれを傾きに合わせた値がセットされるようにします。上記コードで
foreach (var d in data)
{
curve.AddKey(new Keyframe(d.x, d.y));
}
となっている部分の下に
for (int i = 0; i < curve.length; i++)
{
curve.SmoothTangents(i, 0.1f);
}
として、自動でなめらかな曲げ具合になるよう設定して再出力してみました。
良いようです。下記のようにテストプログラムを書くと…
----
StringBuilder sb = new StringBuilder();
foreach (var d in data)
{
var p = curve.Evaluate(d.x);
//sb.AppendLine($"{d.x:0.000}\t{d.y:0.000}\t{p:0.000}");
if (d.y != p)
{
Debug.LogError($"{d.x:0.000}\t{d.y:0.000}\t{p:0.000}");
}
}
foreach (var f in new float[] { 0.625f, 0.81f, 0.911f, 0.945f })
{
var p = curve.Evaluate(f);
sb.AppendLine($"{f:0.000}\t{p:0.000}");
}
File.WriteAllText(Path.Combine(Application.dataPath, "test.txt"), sb.ToString());
----
このような結果が得られます。
0.625 0.268
0.810 0.485
0.911 0.794
0.945 0.967
精度 0.625 のとき、前述の線形補完では 0.27 になっていた倍率は、こちらでは 0.268 と曲線が反映された形で若干低く出るようになりました。
曲線を反映した計算で、実際の pp との誤差が小さくなったか確認してみます。
★10.27 の曲
1 97.17% 579.93 1727228
2 97.09% 572.08 1725883
3 97.04% 566.45 1724883
4 96.95% 558.20 1723380
5 96.90% 553.16 1722451
6 96.78% 542.02 1720359
7 96.71% 535.52 1719103
8 96.67% 531.85 1718376
9 96.66% 531.25 1718256
10 96.66% 530.46 1718097
あ、あれ…?やっぱり微妙に合わない…。なぜ…。
結論から言うと、そもそも「データがない部分も曲線でつながっているのでは?」という推測が間違っていました。データがない部分は、直線の補完で問題ありませんでした。
では、なぜ以前に直線でテストしたときに微妙に数字が合わない結果になっていたかというと
- ScoreSaber のリプレイで pp 計算のテストをしていたが、ScoreSaber のリプレイはスコアが正しく再現されていない場合があった
- ScoreSaber の譜面の★は見えない小数第三位以降もあり、pp 計算にはこれが反映されているために微妙に一致しない
というのが原因でした。
例えば、上の譜面を★10.26926として、直線の補完で計算すると、以下のようにぴったり一致する結果が得られます。
1 97.17% 579.78 1727228
2 97.09% 571.92 1725883
3 97.04% 566.08 1724883
4 96.95% 557.93 1723380
5 96.90% 553.18 1722451
6 96.78% 542.49 1720359
7 96.71% 536.07 1719103
8 96.67% 532.36 1718376
9 96.66% 531.74 1718256
10 96.66% 530.93 1718097
ただ現状、ScoreSaber の★は小数 2 桁しか情報を返してくれないので、NalulunaCounters の pp 計算では微妙な誤差は仕方ない、ということにしています。リーダーボードのスコアと pp の数字から逆算すれば、小数 3 桁目以降も推測できないことはないのですが、ネットワーク通信は必須なとき以外はさせたくないので…。
せっかく作ったので…、ppカーブの比較グラフを出すのに使いました。こういうグラフ表示は python の Matplotlib なんかで簡単に出せますが…、お手軽にマウスで動かしたりできるので便利かもしれない…。
ちなみに、もしかしたら数学的な関数でカーブを出しているかもしれないと思って、最小二乗法を用いた多項式の近似も出してみましたが…、これもダメでしたね。
y = -3.68784924470323E-08x^0 + -42808305.7497644x^1 + 490504607.220839x^2 + -2488814272.92858x^3 + 7340008306.48874x^4 + -13866762663.3801x^5 + 17403856609.3672x^6 + -14512153398.7264x^7 + 7752910758.84015x^8 + -2408086977.93024x^9 + 331345343.489979x^10
x y y_pred
0.000 0.000 0.000
0.600 0.250 0.249
0.650 0.290 0.297
0.700 0.340 0.309
0.750 0.400 0.486
0.800 0.470 0.288
0.825 0.510 0.568
0.850 0.570 0.739
0.875 0.655 0.657
0.900 0.750 0.596
0.910 0.790 0.663
0.920 0.835 0.791
0.930 0.885 0.950
0.940 0.940 1.095
0.950 1.000 1.172
0.955 1.050 1.178
0.960 1.115 1.168
0.965 1.195 1.158
0.970 1.300 1.176
0.973 1.360 1.210
0.975 1.430 1.271
0.978 1.515 1.368
0.980 1.625 1.514
0.983 1.775 1.721
0.985 2.000 2.008
0.988 2.310 2.392
0.990 2.730 2.897
0.993 3.310 3.548
0.995 4.140 4.374
0.998 5.310 5.409
0.999 6.240 6.146
1.000 7.000 6.692