高速で均等なシャッフル手法 ~ 乱数を絞りつくす編

はじめに

配列の要素をランダムな順に並び替える「シャッフル」は、ゲーム分野 (ポーカーや麻雀など) のみならず、機械学習の前処理など、幅広く利用されています。
本稿では、このシャッフルを均等に、かつ高速に行う手法について紹介します。

ランダムソート

まずは悪い例から順を追って見ていきましょう。

こういったコードを見た、あるいは書いた経験のある方も多いかと思います。

static IEnumerable<T> LinqShuffle<T>(IEnumerable<T> source) {  
    return source.OrderBy(_ => Random.Shared.Next());  
}  

乱数をキーとしてソートすることでシャッフルするという、とても簡単に実装できるコードです。

乱数の代わりに Guid を使うパターンもありますね。
本質的には乱数と同じです。

static IEnumerable<T> LinqShuffleGuid<T>(IEnumerable<T> source) {  
    return source.OrderBy(_ => Guid.NewGuid());  
}  

ただ、この手法は結構非効率です。具体的に挙げていきましょう。

ソートなので遅い・均等ではない

OrderBy は .NET 9 時点では安定な クイックソートで実装されています 。
詳しい方はクイックソートは安定ソートではないのでは?と思われたかもしれませんが、この実装では等値だった場合にはインデックスの差を返す比較関数を用いることで安定ソートにしてあります。
クイックソートの平均計算量は O(n \log n) です。速いと言えば速いですが、 O(n) で計算したいところです。

OrderBy は安定ソートのため、キー (ここでは Random.Shared.Next() の値) が重複した場合にもともとの順序が維持されます。
したがって、位置的に先頭に近い値は先頭付近に、末尾に近い値は末尾付近に出現しやすくなります。
直感的にはほぼありえないような確率に思えるかもしれませんが、それなりに現実的に起こりえます。
誕生日攻撃 の計算式 n(0.5; H) \approx 1.1774 \sqrt{H} より、 54562 個程度の要素にソートを行うと約 50% の確率で衝突が発生します。

OrderBy は、メモリ (ヒープ) を O(n) で消費します。これは、 列挙する際に内部的にもとの配列のコピーを作成する ためです。また、固定サイズですが IOrderedEnumerable<TElement> 自体や比較関数などのアロケーションも行われます。大きな配列になってくると地味に重くなってきます。

以上に述べたように、ソートによるシャッフルは実装が楽なかわりに欠点が多く存在します。

ただ、基数ソートといった O(n) のアルゴリズムを利用する、あるいは巨大な配列に対しては並列化可能なソートを利用することで速くなるかもしれません。検討の余地がある……かも……?


手元で試した限りでは、 10 万要素の IEnumerable<int> に対して source.AsParallel().OrderBy(...) で並列ソートした場合に、非並列のものより 2 倍程度早くなりました。ただ、小さいコレクションに対しては 100 倍程度遅くなる・ヒープも数倍~数十倍消費する・並列実行のため擬似乱数まわりの扱いが難しくなるなど難点が多いため、おすすめはできません。

危険なランダムソート


これもソートを使ったシャッフルの実装ですが、絶対にしてはいけません。

static IEnumerable<T> LinqShuffle_Danger<T>(IEnumerable<T> source) {  
    return source.OrderBy(_ => _,   
        Comparer<T>.Create((_, _) => Random.Shared.Next(int.MinValue, int.MaxValue)));  
}  

これはキーではなく比較関数が返す結果をランダムにする手法なのですが、これを行ってしまうとソートの前提となる関係性が破綻してしまいます。
シャッフルの移動先が偏ったり、運が悪いとランダムに以下のような ArgumentException が発生したりします。
「ランダムに」というのが厄介で、テスト時に成功して本番でこける、といった事故も起こりかねません。

System.ArgumentException: 'Unable to sort because the IComparer.Compare()   
method returns inconsistent results. Either a value does not compare   
equal to itself, or one value repeatedly compared to another value   
yields different results. IComparer: 'System.Comparison`1[System.Int32]'.'  

Fisher-Yates shuffle

詳しい方は Fisher-Yates shuffle をご存知かと思います。
このアルゴリズムは非常に効率的です。

static void FisherYates<T>(Span<T> source) {  
    for (int i = source.Length - 1; i >= 1; i--) {  
        int j = Random.Shared.Next(i + 1);  
        (source[i], source[j]) = (source[j], source[i]);  
    }  
}  

Fisher-Yates shuffle の計算量は O(n) で、ランダムソートより効率的です。
メモリを追加で消費することもありません。 (LINQ 版と同じように元の配列を変更しない (inside-out な) 実装にする場合はもちろん元の配列と同じぶん消費しますが、どちらにせよ理想的です。)
加えて、 擬似乱数生成器が理想的であれば 均等にシャッフルされます。ある要素がある位置に配置される確率が全て等しくなります。

このアルゴリズムは .NET 8 Preview 1 で追加された Random.Shuffle でも 利用 されています。

擬似乱数まわりの最適化

さて、 Fisher-Yates shuffle は十分に効率的なうえ、シンプルです。これ自体を改良するのはかなり難しいでしょう。
ここで改良の余地があるのは、 Random.Shared.Next() 、つまり擬似乱数生成の部分です。

擬似乱数生成器の選定

擬似乱数生成器そのものの選定も重要になってきます。

完全に均等なシャッフルを目指すなら CSPRNG (暗号論的擬似乱数生成器; RandomNumberGenerator ) を使う手もあるかもしれませんが、その分パフォーマンスは犠牲になります。
実用的には、十分に内部状態の大きい (より厳密には均等分布次元の大きい) 擬似乱数生成器を使用すべきでしょう。


なお、お金が関わるような場合 (ガチャとか) やゲームの流れを大幅に左右する場合 (麻雀とか) の場合は、 CSPRNG を使用すべきところだと思います。

なぜ内部状態の大きい擬似乱数生成器が必要なのか、について簡単に説明すると、 n 個の要素のシャッフルの結果は n! 通りある以上、擬似乱数生成器側でも n! 通りの乱数が生成できる必要があるためです。
具体例を挙げると、トランプ(ジョーカー 2 枚を含む、 54 枚)では 54! \lt 2^{238} であるので、少なくとも 238 bit 以上の乱数を生成できる必要が出てきます。麻雀牌 (花牌は除く、同種の牌を区別するものとする) なら 136! \lt 2^{773} なので 773 bit 以上必要です。
しかもこれは理想的な実装を行った場合の話で、通常はそれ以上のビット数が必要になります。

Next() を何回も呼び出せば 238 bit ぐらい余裕で生成できるじゃないか、と思われたかもしれませんが、擬似乱数生成器の内部実装によっては「出現しない組み合わせ」が生じる可能性があります。
具体例を挙げると、 64 bit の線形合同法では、 64 bit までなら任意の bit 列を出力できますが、それより大きい bit 列の場合はほぼ確実に出現しない組み合わせが生じます。
より具体的に、以下の線形合同法 Lcg64 を用いて 2 個の連続した出力を観測するとき、最初が 0 なら次は必ず 1442695040888963407 になります。それ以外のペア、例えば [0, 0] などは絶対に出力されません。

static ulong Lcg64(ref ulong state)  
    => state = state * 6364136223846793005 + 1442695040888963407;  

したがって、 64 bit の線形合同法を用いてトランプをシャッフルしようとした場合、絶対に生成されない組み合わせや、出やすい組み合わせが出てきてしまいます。この場合は、最低限 xoshiro256++ (256 bit) 、余裕をもって xoroshiro1024++ (1024 bit) などを使用すべきでしょう。

それでいて、もちろん高速性も重要です。
例えば、 メルセンヌツイスタ mt19937 であれば 19937 bit まで生成できるので大抵の用途のシャッフルに耐えます ( 2081! \lt 2^{19937} ; 理論上は 2081 枚のカードを均等にシャッフル可能) 。ただ、速度はモダンな擬似乱数生成器に比べると遅いです。

主要な (?) アルゴリズムの内部状態の bit 数と、それによってシャッフルできるカードの枚数上限を示します。

Algorithm bits cards
LCG (線形合同法) 64 20
xoroshiro128+ 128 34
shioi128 128 34
seiran128 128 34
xoshiro256** 256 57
culumi 256 57
xoroshiro1024* 1024 171
mt19937 (メルセンヌツイスタ) 19937 2081
SCP-1214-EX 4749265984 182651279

また余談です。シャッフルに限った話ではありませんが、擬似乱数生成器の初期化にも気を配る必要があります。例えば、メルセンヌツイスタの初期化関数には 32 bit のシードを受け取るものがありますが (オリジナル実装 の init_genrand(unsigned long s)) 、これを利用してしまうと高々 2^{32} 通りの系列 (シャッフル結果) しか得られなくなってしまいます。初期化時には内部状態以上の情報量を持ったソース (CSPRNG など) を用いて、全域をまんべんなくランダムにする必要があります。


それなら最初から CSPRNG を使えばよくない?という話もあります。難しいですね。
まぁ現実的には実行速度や取り回しのしやすさ、再現性の担保のために普通の PRNG を使うことになるでしょう。

その際のポイントとしては、できる限り擬似乱数生成器インスタンスを使いまわすこと (都度初期化しないこと) が挙げられます。シード値が擬似乱数生成器の内部状態より小さい場合はなおさら。
擬似乱数生成器は使い続けることを前提に設計されており、初期化直後 (特に小さなシード値によるもの) はランダムでない (何らかの相関があったり、立っているビット数が少なかったりする) 値を出力する場合があります。

乱数を絞りつくす

話を戻して、 Fisher-Yates shuffle のコードをもういちど見てみましょう。

static void FisherYates<T>(Span<T> source) {  
    for (int i = source.Length - 1; i >= 1; i--) {  
        int j = Random.Shared.Next(i + 1);  
        (source[i], source[j]) = (source[j], source[i]);  
    }  
}  

これを見ると、 n 個の要素に対して n - 1 回の Next() 呼び出しがあることがわかります。
Next() 、つまり乱数生成は相対的に重い処理であるため、この部分の最適化を図りたいです。

64 bit 環境向けの擬似乱数生成器は、大抵の場合一度に 64 bit の乱数を生成できますので、 2^{64} 通りの乱数を得ることができます。
例えば 100 要素のシャッフルなら、一回あたり高々 100 通りぶんの乱数しか必要ないのですから、 1 回で 2^{64} ぶんの情報量を持つ乱数を消費してしまうにはもったいないです。
相対的に重い Next() 呼び出しの回数を減らすため、できる限り乱数を絞りつくす必要があります。

絞りつくすといいことがもう一つあります。乱数を絞りつくす実装では、必要な均等分布次元数 (≒ 内部状態の bit 数) を減らすことができます。例えば 20 要素のシャッフルに対して 19 回 Next を呼ぶ素朴な実装では、 Next が 64 bit の乱数を出力する場合 64 \times 19 = 1216 bit ぶん必要であるのに対し、絞りつくす実装では 1 回の Next 呼び出しでよい ( 20! \lt 2^{64} ) ので 64 bit で済みます。

乱数を絞りつくす実際の工程はこんな感じです。

  1. n = \prod_{k=2}^{20} を求める
  2. 64 bit 擬似乱数 r を生成する
  3. r を 0 \le s \lt n の範囲に均等に変換できるように調整する
  4. r を分割してインデックス 2 ~ 20 ぶんの乱数を得る
  5. 得た乱数で Fisher-Yates shuffle を行う
  6. n = \prod_{k=21}^{33} を求め、以下繰り返し

それぞれの工程について詳しく見ていきましょう。

n を求める

n = \prod_{k=2}^{20} = 2 \times 3 \times \ldots \times 20 = 2432902008176640000 を求めます。
これは何からきているかというと、 ulong で表現可能な ( 2^{64} より小さい) 範囲で最大の階乗の数です。

64 bit 擬似乱数 r を生成する

ulong 全域に一様分布する乱数 r を生成します。
System.Random には残念ながら NextUInt64() は生えていないので、自前でお好みのアルゴリズムを実装した擬似乱数生成器があると良いでしょう。高速なものをチョイスすればより高速に、均等分布次元の高いものをチョイスすればより大きな配列のシャッフルに使えます。


一応 Random.NextBytes() で頑張れば不可能ではありませんが、オーバーヘッドがあるかもなので素直に自作することをおすすめします。

実は内部的には NextUInt64() は実装されているのですが、 internal なので触れません。残念。

r を 0 \le s \lt n の範囲に均等に変換できるように調整する

ここは一般的な擬似乱数生成における範囲変換と同様で、 Next(int max) などと同じイメージです。
ただ注意すべきなのは「均等に変換」というところです。
例えば、安直に r % n で変換した場合は n が 2 の冪乗でない限り最小値付近が最大値付近より出やすくなります。

具体的には、 r % n で範囲変換した場合、 \lbrack 0, 2^{64} \bmod{n}) の範囲の数は \lfloor 2^{64} / n \rfloor + 1 個、 \lbrack 2^{64} \bmod{n}, n) の範囲の数は \lfloor 2^{64} / n \rfloor 個出現します。
したがって、確率に偏りが出ます。

そのため、r を再生成する・別の乱数と組み合わせて補正をかけるなどして、均等に出るようにします。

今回は、 Swift で提案されている手法 をベースにして調整を行います。
この手法は、もともとは一様分布の乱数を特定の範囲に偏りなく変換するための手法です。
Math.BigMul を巧みに使うことによって重い除算や剰余算をする必要をなくし、また Lemire 氏が提案している方式 に比べて連続再試行となる確率が低いという特徴があるため、高速に実行することができます。


具体的には、Swift 式は i 試行目で再試行になる確率は n / 2^{64i} と指数関数的に低くなっていきます。
対して Lemire 式の場合は (2^{64} \bmod n) / 2^{64} と i に依存しない定数となります。最初の 1 回の確率は Swift 式に比べて低くなりますが、試行回数を重ねても一定です。

加えて Lemire 式の場合、 2 回目の乱数を振る前に剰余算 % を実行する必要があります。これは場合によっては 1 回の乱数生成に匹敵するレベルの時間がかかります。
したがって、今回の用途では Swift 式のほうが有利と判断しました。

Swift 式の実装例を以下に示します。

// factorial は本文中の n に対応; 範囲の上限 (この値を含まない)  
  
// 64 bit 乱数 r を生成  
ulong r = rng.Next();  
  
ulong rlo = r * factorial;  
  
// r * factorial の下位 64 bit (rlo) を見て、繰り上がりの可能性があれば…  
// (後続の処理で足される最大値が factorial - 1 なので、  
//  rlo <= (2^64) - factorial なら繰り上がりは発生せず、処置不要)  
while (rlo > 0ul - factorial)  
{  
    // 追加で乱数 t を生成し、繰り上がるかを調べる  
    // 下記の筆算をやるイメージ  
    //   [rhi] . [rlo]        -> r * factorial の 上位 rhi / 下位 rlo  
    // +     0 . [thi] [tlo]  -> t * factorial の 上位 thi / 下位 tlo  
    // ---------------------  
    //   [carry   sum] [tlo]  -> rhi + carry が求めるべきもの  
    ulong thi = Math.BigMul(rng.Next(), factorial, out ulong tlo);  
    ulong sum = rlo + thi;  
    ulong carry = sum < thi ? 1ul : 0ul;  
  
    // sum == 0xffff...ffff であれば、今後繰り上がりの可能性があるのでもう一度  
    // そうでなければこれ以上繰り上がりは発生しないので、 carry を足して終了  
    if (sum != ~0ul)  
    {  
        // r に carry(1) を足す → rlo が factorial 増える →   
        // while の条件式から必ずオーバーフローするので rhi が 1 増える  
        r += carry;  
        break;  
    }  
  
    rlo = tlo;  
}  
  
// rhi は偏りなく 0 <= x < factorial の範囲に分布する一様乱数  
ulong rhi = Math.BigMul(r, factorial, out _);  

お分かりいただけたでしょうか?
私は最初このアルゴリズムを見たとき感動しました。よく思いつきますね……


Lemire 式で実装する場合はこのようになります。

// 64 bit 乱数 r を生成  
ulong r = rng.Next();  
  
ulong rlo = r * factorial;  
  
// 事前チェック。常に下式は成立するので、  
// (0 - factorial) % factorial < factorial  
// この if で弾ければ時間のかかる % をスキップできる  
if (rlo < factorial)  
{  
    // 2^64 % factorial == (2^64 - factorial) % factorial  
    ulong mod = (0 - factorial) % factorial;  
  
    // 0 <= rlo < mod の場合、再抽選  
    while (rlo < mod)  
    {  
        r = rng.Next();  
        rlo = r * factorial;  
    }  
}  
  
// rhi は偏りなく 0 <= x < factorial の範囲に分布する一様乱数  
ulong rhi = Math.BigMul(r, factorial, out _);  

体感ですが、通常時の範囲変換はこちらのほうが速い場合が多いです。
Lemire 式のほうが乱数を複数生成する確率が低いので、特に乱数生成が重い場合に有利になりがちです。
使い分け (とベンチマーク) が大切ということかもしれません。

r を分割してインデックス 2 ~ 20 ぶんの乱数を得る

r が計算できたら、各インデックスを取り出します。

int t2 = (int)Math.BigMul(r, 2ul, out r);   // [0, 2)  
int t3 = (int)Math.BigMul(r, 3ul, out r);   // [0, 3)  
// ...  
int t20 = (int)Math.BigMul(r, 20ul, out r);   // [0, 20)  

64 bit . 64 bit の固定小数点数をイメージするとわかりやすいかもしれません。
最初の r が 0.r 、つまり 0 ~ 1 の乱数と見立てて、 2, 3, ... を掛けたときの上位 64 bit = 整数部分を得ることで 0 以上 2, 3, ... 未満の乱数を取得します。

論文 "Batched Ranged Random Integer Generation" *1 では、可変進数のような考え方をしていました。 1! の位 (0 ~ 1) から始まり、 2! の位 (0 ~ 2)、 3! の位 (0 ~ 3)、…… といった感じです。

Fisher-Yates shuffle を行う

ここはベースのコードとほぼ同じです。
違う点があるとすれば、オリジナルのコードでは i <= x < source.Length の範囲でランダムなインデックスを生成していましたが、こちらでは 0 <= x < i の範囲で生成しています。
for 文を i++; で回すことによって、 n を事前に計算してキャッシュしておけるようにするためです。
こういうことをしても大丈夫か、と不安になるかもしれないので、数学的帰納法っぽく証明?しておきます。

まず、長さ 1 の配列は、各要素 (といっても要素 [0] だけです) が均等な確率 (1) で各位置 ([0]) に存在するので、均等にシャッフルされていると言えます。
次に、長さ k の配列があり、それは均等にシャッフルされているとします。この配列に k+1 番目の要素を追加したうえでランダムに i (1 \le i \le k + 1) 番目の要素と交換したとき、均等にシャッフルされていると言えるでしょうか。
まず、追加した k + 1 番目の要素は等しい確率 (\frac{1}{k+1}) で全ての場所に移動するため、均等であると言えます。その他の要素は移動していないか、 \frac{1}{k+1} の確率で末尾と交換されたかなので、各位置に均等な確率で存在している状態を維持します。
以上から、このシャッフル方式でも問題なくシャッフルできるといえます。ふわっとしていますがこんな感じでいかがでしょうか……

次の n を求めて、必要なぶん繰り返す

n = \prod_{k=21}^{33} = 21 \times 22 \times \ldots \times 33 = 3569119343741952000 を求めて、同様に操作を繰り返します。
これを元の配列の長さと同じ分までやります。
途中まで必要であれば (長さが 25 だった場合など) 、そこまでで乗算を打ち切ってしまってよいです。

結果

ベンチマーク結果を示します。
BatchedSwift が上記の「乱数を絞りつくした」実装です。
DataClass は record DataClass(double x, double y, double z, double w) のクラスです。クラスと構造体で性能特性が違う可能性を考慮してテストしています。

Method array Mean Error StdDev
LinqSort DataClass[1024] 52,252.68 ns 1,007.564 ns 1,237.379 ns
FisherYatesSwift DataClass[1024] 8,163.16 ns 63.845 ns 59.721 ns
BatchedSwift DataClass[1024] 6,221.22 ns 101.979 ns 90.401 ns
LinqSort DataClass[32] 969.79 ns 11.481 ns 10.739 ns
FisherYatesSwift DataClass[32] 261.47 ns 2.584 ns 2.417 ns
BatchedSwift DataClass[32] 134.73 ns 2.616 ns 2.569 ns
LinqSort Int32[1024] 49,661.06 ns 727.360 ns 680.373 ns
FisherYatesSwift Int32[1024] 3,425.73 ns 46.750 ns 43.730 ns
BatchedSwift Int32[1024] 1,532.79 ns 18.482 ns 16.384 ns
LinqSort Int32[32] 878.98 ns 10.204 ns 7.966 ns
FisherYatesSwift Int32[32] 94.11 ns 1.894 ns 2.528 ns
BatchedSwift Int32[32] 32.54 ns 0.227 ns 0.212 ns

Linq とは比べ物にならないレベルで Fisher-Yates 群が速いです。それはそう。
また、 Batched は生の Fisher-Yates に比べて 1.5 ~ 2 倍程度早くなっていることが分かります。

小手先の高速化

アルゴリズムレベルではない、小手先の高速化手法について書きます。
うまくいったやつとそうではないやつがあるので注意してください。

Next() メソッドの (手動) インライン展開

乱数生成をインライン展開することで高速化を図ります。

例えば、

for (int i = 0; i < length; i++)  
{  
    ulong r = rng.Next();  
    // do something  
}  

これを、こういう感じにします。

var state = rng.State;  
for (int i = 0; i < length; i++)  
{  
    ulong r = StaticNext(state);  
    // do something  
}  
rng.State = state;  
  
// ---  
  
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
static ulong StaticNext(State state) { /* same as Next()*/ }  

rng.State の更新を最後に移動させ、 Next() を静的関数に実装しなおしているのがポイントです。

関数呼び出しをスキップできるようになるほか、手動で工夫して展開すると都度メモリに書かずにレジスタ上で完結するようになるため、多少の高速化が見込めます。

もちろん、擬似乱数生成器とべったり癒着することになるので、一長一短です。

上限を削る

乱数の再生成が必要になるのは r > 0 - n の場合でしたね。つまり、 n が小さいほど乱数を再生成する確率が下がります。
今まで n = \prod_{k=2}^{20} として計算していましたが、これを n = \prod_{k=2}^{18} のようにしたらどうでしょうか?

均等分布次元が減るのと引き換えに、棄却率を下げて再生成 (=遅延) を減らそう、という試みです。
ちょっと試した感じでは n \lt 2^{58} の制約をつけたときに速度のバランスが良い、ということがわかっていますが、均等分布次元を犠牲にするほどの劇的な加速は得られていませんので、微妙です。

タプルでの交換をやめる

現代の C# では、以下のコードで要素のスワップができます。

(span[a], span[b]) = (span[b], span[a]);  

しかし、これはどうしてか、以下の従来のコードのほうとアセンブリの生成結果が異なる場合があります。

var t = span[a];  
span[a] = span[b];  
span[b] = t;  

体感としては、タプルを使わないコードのほうが簡潔なアセンブリを生成する傾向があります。

具体例は Sharplab を確認してみてください。

SIMD 化

C# では Vector128 などを経由して SIMD 化することができます。

このコードで SIMD 化できそうなところとしては、

  • n = \prod_{i}^{k} の計算
  • 各インデックスの計算

が挙げられます。
ただ、ちょっと試した限りではオーバーヘッドのほうが大きく、高速化にはつながりませんでした。

配列アクセス時の範囲チェック削除

// values[m] = something;  
Unsafe.Add(ref MemoryMarshal.GetReference(values), m) = something;  

こう書くと IndexOutOfRangeException を飛ばすコードがなくなります。
このコードは高速性と危険性が表裏一体なので、十分なデバッグをしてから最後に実装してください。

n のキャッシュ

n の値は実行ごとに変わらないので、キャッシュしておいたり事前計算して埋め込んでおいたりすることもできます。

静的キャッシュ (事前計算して switch) や動的キャッシュ (Dictionary に登録) など試してみましたが、手間の割に高速化しませんでした。なので初手の n = 20! だけ埋め込むのがよさそうです。

Fisher-Yates shuffle の誤った実装例


誤った例ですので、真似しないでください!

例えば、乱数生成の範囲指定で +1 し忘れた場合 (0 \le r \lt i) 、以下のようになります。

static void FisherYates_Wrong_OffByOne<T>(Span<T> source) {  
    for (int i = source.Length - 1; i >= 1; i--) {  
        int j = Random.Shared.Next(i); // instead of i + 1  
        (source[i], source[j]) = (source[j], source[i]);  
    }  
}  

この場合、「サットロのアルゴリズム」という変種になり、円順列を生成するようになります。
また、ある要素がシャッフル後に同じ位置に配置される確率が 0 になります。

また、乱数生成の範囲指定で常に配列全部の範囲を指定した場合も、偏りが生じてしまいます。

static void FisherYates_Wrong_Entire<T>(Span<T> source) {  
    for (int i = 0; i < source.Length; i++) {  
        int j = Random.Shared.Next(source.Length); // instead of i + 1  
        (source[i], source[j]) = (source[j], source[i]);  
    }  
}  

初心者が何も見ずに実装するとこうなる場合が多い気がします。

交換によって生じるパターン数が n^{n} になる一方で、シャッフルによって生じるパターン数は n! です。 n^{n} \not\equiv 0 \mod{n!} ですので、必ず偏りが生じます。

実際にどのように偏るのかについては、 Wikipedia が詳しいです。

https://www.sega-mj.com/arcade/viewer/haiyama/viewer.html

ところで、シャッフルの実例としてコードを探していたところ、これを見つけました。
この「サンプルコード」の実装ではまさしく上記の間違ったシャッフル法が実装されています。
そのうえ範囲変換が剰余で実装されているのでそこでも偏っています。二重苦。

MergeShuffle

さて、高速化にあたって思いつく手法のひとつとして、並列化が挙げられます。
並列にシャッフルを実行するアルゴリズムとして、 MergeShuffle があります。 *2

実装例はこんな感じです。分割統治法のような感じですね。
2^{n} 個の領域に分割してそれぞれ Fisher-Yates でシャッフルし、それらをマージしていく感じです。

public static void Shuffle<TRng, TSpan>(TRng rng, Span<TSpan> span)  
    where TRng : IRandom  
{  
    Divide(rng, dist, span);  
}  
  
private static void Divide<TRng, TSpan>(TRng rng, Span<TSpan> span)  
    where TRng : IRandom  
{  
    if (span.Length <= 16)  
    {  
        FisherYates(rng, dist, span);  
    }  
    else  
    {  
        Divide(rng, dist, span[..(span.Length / 2)]);  
        Divide(rng, dist, span[(span.Length / 2)..]);  
        Merge(rng, dist, span);  
    }  
}  
  
private static void Merge<TRng, TSpan>(TRng rng, Span<TSpan> span)  
    where TRng : IRandom  
{  
    int start = 0;  
    int mid = span.Length / 2;  
    int end = span.Length - 1;  
  
    ulong r = rng.Next();  
    int entropy = 64;  
  
    while (true)  
    {  
        // エントロピーがなくなったら補充  
        if (entropy == 0)  
        {  
            r = rng.Next();  
            entropy = 64;  
        }  
  
        // 1 bit 取り出す  
        ulong bit = r & 1ul;  
        r >>= 1;  
        entropy--;  
  
        // bit 1 なら [start] と [end] を交換  
        if (bit == 0)  
        {  
            if (start == mid)  
            {  
                break;  
            }  
        }  
        else  
        {  
            if (mid == end)  
            {  
                break;  
            }  
            (span[start], span[end]) = (span[end], span[start]);  
            mid++;  
        }  
  
        start++;  
    }  
  
    while (start < end)  
    {  
        // [0, start) の乱数を生成、それと [start] を交換  
        int index = (int)rng.Next((ulong)start);  
        (span[start], span[index]) = (span[index], span[start]);  
        start++;  
    }  
}  

ただ、実際に実装してみると遅いです。 Fisher-Yates に処理を足している感じなのでそれはそう。実装が悪いだけかもしれませんが。
乱数生成やシャッフルをうまく並列化できれば、大きな配列に対して効果が見込めそう……ではあります。

Feistel 構造を利用したシャッフル

面白い性質を持ったシャッフル手法のひとつとして、 Feistel 構造 を利用したシャッフルが挙げられます。

Feistel 構造は、 ブロック暗号の構成法の一種です。 DES などで使われています。
簡単な実装例は以下のようになります。

// internal state  
uint left = ..., right = ...;  
  
// 4 rounds (any number of rounds)  
for (int round = 0; round < 4; round++)  
{  
    (left, right) = (right, left ^ Round(right));  
}  
  
// here, left and right are encrypted  
  
uint Round(uint x) => /* returns any value */;  

ここでポイントとなるのは、 Round() には任意の関数を用いることができることです。
速度と品質を天秤にかけて、 (いい意味で) 適当な関数を設定できます。


さて、ブロック暗号とシャッフルに何の関係があるのか、と思った方もいるかと思います。

暗号化できるということは、復号もできます。それはそう。
そして復号ができるということは、ある種の全単射関数のように振る舞うということです。
どういうことかというと、例えば 4 bit の Feistel 構造を構成して連番 [0, 1, 2, ..., 15] を入力したとき、それを暗号化した後の値は [0, 12, 8, ..., 7] みたいになるのですが、これは連番と一対一対応する、すなわち連番の順序を「シャッフル」したものと同じになります。
ということはつまり、シャッフルに使える、というわけです。

具体的な流れとしては、

  1. n 要素の並べ替えをしたいとき、 n \lt 2^{2b} を満たす 2b ビットの Feistel 構造をつくる
  2. i でループ
    1. i を暗号化して f(i) を求める
    2. f(i) \lt n なら、 f(i) 番目の要素を返す (yield return)

という感じです。
「2b ビットの Feistel 構造」は、単に uint のペア (64 bit) にビットマスクを掛ければよいです。具体的には、 18 bit が必要なら 0x1ff (9 ビット) のマスクを掛ければそれが 2 個なので 18 bit になります。

このシャッフルの利点は、 i 番目の要素がどこに移動したかを O(1) で取得できる点です。 Fisher-Yates の場合は全要素の処理が終わるまで座標は確定しませんが、 Feistel 構造なら i を暗号化するだけなので長さに依存せずに座標を取得できます。なので、超巨大な配列からいくつかの要素をランダムに抽出したい、といった用途については効率的に行えるかもしれません。
また、面白い性質としては、復号することでシャッフルを「元に戻す」ことができます。

対して、欠点としては、要素数が 2 冪でない場合の処理が結構めんどくさいことが挙げられます。要素数が 2 冪でない場合、範囲外参照になる場合があるので、それを読み飛ばす必要が出てきます。 yield return するような実装ならこれは簡単なのですが、インプレースな (追加領域を確保せず、元の配列をいじるような) 実装は難しいです。
また、 1 つの要素を取得するのにかかる時間が比較的長くなってしまう問題があります。ちゃんとした暗号化 (シャッフル) をするためには最低でも 2 ラウンド必要ですし、きちんとしたハッシュ関数を使う必要があります。それに対して、 Fisher-Yates であれば 1 つあたり 1 回の乱数生成、より最適化すれば 1 回の乗算と数回に 1 回の乱数生成だけで済んでしまいます。

おわりに

いろいろなシャッフル手法と、高速で均等なシャッフルを行うにあたっての工夫についてまとめました。
バニラの Fisher-Yates より速い手法がある、というのを初めて知った時は驚きました。

最後に、高速で均等な実行ができる手法の実装例を挙げておきます。

Fast shuffle by batching

*1:Brackett‐Rozinsky, Nevin, and Daniel Lemire. "Batched ranged random integer generation." Software: Practice and Experience (2024).

*2:Bacher, Axel, et al. "Mergeshuffle: a very fast, parallel random permutation algorithm." arXiv preprint arXiv:1508.03167 (2015).

高速で頑健なシェーダー乱数の比較と提案

はじめに

シェーダー (HLSL) で乱数を発生させたくなることがしばしばあります。
直接的にはホワイトノイズ、間接的にはパーリンノイズなどが挙げられます。

そういうシェーダーを書いたことがある方は、 frac(sin(...)) みたいな擬似乱数生成器を目にしていることでしょう。
ですが、乱数を発生させる手法は星の数ほど存在します。品質や速度もそれぞれ異なる擬似乱数生成器がたくさんあります。

本稿ではそれらのシェーダー乱数の性能比較を行っていきたいと思います。

シェーダー乱数の定義

さて、乱数とはいっても、シェーダーでよく使われる乱数は CPU ベースの (メルセンヌツイスタ とかの) 擬似乱数生成器とは設計レベルで異なることが多いです。

まず、状態を持たない、ある種のハッシュ関数のような実装をすることがほとんどです。
大抵の場合、座標を引数にとることになるでしょう。毎フレーム変化する乱数が欲しい場合には、次元の一つに時間を入れればよいです。

そして、出力の値域です。
CPU ベースのものなら uint32_t や uint64_t 型全部をカバーするような場合がほとんどです。
しかし、今回の用途はシェーダ用なので float 型だったり、最悪 \lbrack 0, 255 \rbrack ぐらい取れれば OK 、みたいな場合があります。
例えば、改良パーリンノイズなら 12 通りの乱数が得られれば動きます。ホワイトノイズも (HDR でなければ) 256 通りあれば十分でしょう。
今回は、返り値は float の \lbrack 0, 1 ) で、最低でも 256 通り (ただし、できれば 2^{16} 通り以上) の値が得られることを要件とします。

以上から、シグネチャはこんな感じになりそうですね。

// returns [0, 1)  
float rand(float4 pos)  
{  
    ...  
}  

このシグネチャに合わない関数は、概ね以下の方針で改造するものとします。

  • 入力が足りない場合
    • float のみ:繰り返し呼ぶ f(f(f(f(x) + y) + z) + w)
    • float2 など: f(v.xy + v.zw)
  • 出力が多い場合 (float2 とかを返す場合)
    • ひとつだけ使う: f(v).x
    • 束ねる (総和をとる、 xor するなど): dot(f(v), 1)

また、入力 pos は整数座標が入っていると仮定してもよいものとします。
つまり、 (0.0, 0.0) と (0.4, 0.9) が同じ値を返すことを許容します。
これはどうしてかというと、第一に uint ベースのアルゴリズムが多いため、第二にパーリンノイズなどの勾配ベクトルを生成する用途では整数格子なので小数以下はそもそも見ていないためです。
ついでに ±∞ や NaN 、非正規化数などへの対処は考えなくてよいものとします。


asint() 関数で直接 float から int へ変換することもできますが、基本的には普通に int (uint) へのキャストをすることにします。

乱数の品質については、擬似乱数生成器の品質を測れるテストスイートである PractRand でテストすることにします。
詳しくは後述します。

なお、本稿での float は CPU での float と同じ、 IEEE 754 規格の 32bit 単精度浮動小数点数とします。


half や fixed は精度が異なるためアルゴリズムが破綻しますので、考えないものとします。
特にモバイル向けのシェーダを書く場合、乱数関係の実装においては必ず float 精度またはそれに相当する指定を行ってください。

以上をまとめると、シェーダー乱数の要件は、

  • 状態を持たない
  • シグネチャは float rand(float4 pos)
  • pos は整数としてよい
  • 戻り値は \lbrack 0, 1 ) 、最低 256 通り以上

です。

PractRand テストについて

PractRand は、 2024 年現在主力となる擬似乱数生成器のテストスイートです。
diehard(er) や TestU01 といった他のテストスイートに比べると格段に検出力が高いほか、比較的早く問題を発見することができます。


具体的には、 TestU01 の BigCrush だとどんなに速くとも 3 時間程度かかるうえに偽陽性が出る (真の乱数であっても検定に失敗する) ことがあるのですが、 PractRand では最速で 1 秒程度で問題検出されるうえ、偽陽性も今のところ確認されていません。

PractRand のテストに落ちるということは、「少なくとも (バイト長) 出力したときに、なんらかの統計的・構造的問題が見られ、真の乱数ではないと見破れる」ことを示します。テストに落ちても乱数として「全く」役に立たないということではないですが、客観的な品質の指標として役立ちます。

今回は 64 KiB ( 2^{16} バイト) をテストの開始地点とし、 1 TiB ( 2^{40} バイト) までテストに通れば高品質だと定義することにします。
本稿では PractRand ver. 0.94 を使用しました。
ちなみに体感としては、 64 KiB で即座に失敗する擬似乱数生成器は人間でも目に見えるレベルで問題があります。

とても雑にまとめると、テストに落ちるまでのバイト長が長ければ乱数として強いということです。


本来の擬似乱数生成器のテストでは 32 TiB 程度まで見るほか、検定を expand モードにして実施したりしますが、時間がかかるので今回はそこまでしません。
参考までに、 1 TiB のテストには実測 1 ~ 2 時間かかりますので、 32 TiB のテストには単純計算で 32 ~ 64 時間 (1 日半 ~ 2 日半) かかります。

テスト用コードはこういう感じです。
0, -1, +1, -2, +2, -3, +3, ... という感じに進めていき、 65536 になったら 0 に戻して次の次元を加算に行きます。
また、コードに書いたように float の返り値の上位 16 bit だけをチェックするものとします。実用上、 float の下位ビットに何かあってもほぼ問題にならないと考えたためです。

class DummyRNG : public PractRand::RNGs::vRNG16 {  
public:  
    int x, y, z, w;  
  
    void increment() {  
        x = x >= 0 ? ~x : -x;  
        if (x >= 65536) {  
            x = 0;  
        }  
        else return;  
  
        y = y >= 0 ? ~y : -y;  
        if (y >= 65536) {  
            y = 0;  
        }  
        else return;  
  
        z = z >= 0 ? ~z : -z;  
        if (z >= 65536) {  
            z = 0;  
        }  
        else return;  
  
        w = w >= 0 ? ~w : -w;  
        if (w >= 65536) {  
            w = 0;  
        }  
    }  
  
    Uint16 raw16() {  
        increment();  
        // call some shader hashes  
        return (Uint16)(shader(x, y, z, w) * 65536);  
    }  
  
    void walk_state(PractRand::StateWalkingObject* walker) {  
        x = y = z = w = 0;  
    }  
  
    std::string get_name() const { return "shaderimpl"; }  
};  

ちなみに、テストにかけるために全部 C++ に移植する羽目になり泣いていました。

シェーダー乱数一覧

シェーダー乱数を集めるにあたって、網羅的に調査されている素敵な論文 "Hash functions for gpu rendering" を見つけました。 *1
基本的にはこの論文からチョイスして、後は私が見つけたものについて調査します。


この論文の "Detailed hash results and code" に再現用のコードが載っているのですが、それぞれの出典元と比較したところ異なる実装が行われている場合がいくつか見受けられました。
本稿ではなるべく出典元の実装を尊重するため、一部で当該論文とは異なる実装を行っている場合があります。

なお、シグネチャが float rand(float4 v) ではないものに関しては、できるだけオリジナルに近い実装を rand_orig() 関数として実装した後、変換コードを rand() に書く、という運用をしました。

各乱数には、以下の表をつけています。

key value
Instruction Mix ピクセルシェーダーの命令数
GPU Duration 描画にかかった時間
FPS 実測 Frames per Second
PractRand Failed PractRand で検定失敗するバイト長

Instruction Mix は、ピクセルシェーダーの命令数です。 NVIDIA Nsight Graphics で測定しました。

GPU Duration は、 1024x1024 のテクスチャに描画したときにかかった時間です。これも NSight Graphics で測定しました。

FPS は、 1024x1024 の quad (UI) を用意して描画したときの FPS です。しばらく動作させて最大の数値を記録しました。

PractRand Failed は、 PractRand でテストに失敗したときのバイト長 (2冪) です。
大きければ大きいほど品質が良いです。テストに失敗しなかった場合は > 2^41 (→ 2 TiB の検定にパスした) のように最低限確認できたところまで記載しています。

速度の参考までに、白塗りするだけのシェーダーはこんな感じでした。

key value
Instruction Mix 0
GPU Duration 25.60 μs
FPS 2676
PractRand Failed ×

改良パーリンノイズの定数テーブル

static int perlin_permutation[512] = {  
    151,160,137,91,90,15,  
    131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,  
    190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,  
    88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,  
    77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,  
    102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,  
    135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,  
    5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,  
    223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,  
    129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,  
    251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,  
    49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,  
    138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180,  
  
    151,160,137,91,90,15,  
    131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,  
    190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,  
    88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,  
    77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,  
    102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,  
    135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,  
    5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,  
    223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,  
    129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,  
    251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,  
    49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,  
    138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180  
};  
  
float perlinperm(float4 pos)  
{  
    return   
        perlin_permutation[  
            perlin_permutation[  
                perlin_permutation[  
                    perlin_permutation[int(pos.x) & 0xff]  
                    + (int(pos.y) & 0xff)]  
                + (int(pos.z) & 0xff)]  
            + (int(pos.w) & 0xff)] * (1.0 / 256.0);  
}  

これは、改良パーリンノイズの勾配ベクトル生成に使われている擬似乱数生成器です。 *2
事前に定数テーブルを生成しておいて、それをうまく参照することで乱数を生成します。

定数テーブルは、 \lbrack 0, 255 \rbrack の連番を適当にシャッフルしたものを 2 つつなげて長さ [512] にすることで生成します。
2 つつなげることで、都度 & をとる必要がなくなっています。

利点としては、定数テーブルをシャッフルしなおすことでシード値の設定が可能なこと、テーブルさえコピペしてしまえば実装が簡単なことが挙げられます。
欠点としては、座標 \lbrack 0, 255 \rbrack までしか対応していないこと (それ以降はループします) 、それなりのサイズの定数テーブルが必要なため、定数ロードに時間がかかる可能性があることが挙げられます。

微妙にパターンが見える気がしますね。

key value
Instruction Mix 21
GPU Duration 128.0 μs
FPS 2501
PractRand Failed 216

案の定テストにも即座に落ちています。

mod289

float mod289_permute(float i)  
{  
    float im = mod(i, 289.0);  
    return mod(((im * 34.0) + 10.0) * im, 289.0);  
}  
  
float mod289(float4 i)  
{  
    return mod289_permute(  
        mod289_permute(  
            mod289_permute(  
                mod289_permute(i.w)  
                + i.z)  
            + i.y)  
        + i.x) * (1.0 / 289);  
}  

これは、タイル化可能シンプレックスノイズ (psrdnoise) の論文で使用されている、勾配ベクトル生成用の擬似乱数生成器です。 *3

x_{n+1} = 34 x_n^{2} + 10 x_n \mod{289} の遷移式を使って生成します。

これはさざ波のようなパターンが見える気がしますね。

key value
Instruction Mix 39
GPU Duration 27.65 μs
FPS 2656
PractRand Failed 216

案の定テストにも落ちています。

FNV1

uint fnv1_orig(float x, float y, float z, float w)  
{  
    const uint prime = 16777619;  
      
    uint ret = 2166136261;   
    uint key = x;  
  
    ret *= prime;  
    ret ^= ((key >> 0) & 0xff);               
    ret *= prime;  
    ret ^= ((key >> 8) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 16) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 24) & 0xff);  
  
    key = y;  
  
    ret *= prime;  
    ret ^= ((key >> 0) & 0xff);               
    ret *= prime;  
    ret ^= ((key >> 8) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 16) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 24) & 0xff);  
  
    key = z;  
  
    ret *= prime;  
    ret ^= ((key >> 0) & 0xff);               
    ret *= prime;  
    ret ^= ((key >> 8) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 16) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 24) & 0xff);  
  
    key = w;  
  
    ret *= prime;  
    ret ^= ((key >> 0) & 0xff);               
    ret *= prime;  
    ret ^= ((key >> 8) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 16) & 0xff);  
    ret *= prime;  
    ret ^= ((key >> 24) & 0xff);  
  
    return ret;  
}  
  
float fnv1(float4 v)  
{  
    return fnv1_orig(v.x, v.y, v.z, v.w) * 2.3283064365386962890625e-10;  
}  

GPU でのハッシュ関数の実装について検討された論文に載っていました。 *4
FNV ハッシュについては Wikipedia を参照してください。

ちなみに、最後に掛けられている定数 2.3283064365386962890625e-10 は 2^{-32} (1.0 / 4294967296) です。

これも小さなさざ波のようなパターンが見える気がします。

key value
Instruction Mix 50
GPU Duration 30.72
FPS 2656
PractRand Failed 216

案の定テストに落ちています。

JenkinsHash

uint jenkins_orig(float4 v)  
{  
    uint ret = 0;  
  
    uint key = v.x;  
  
    ret += ((key >> 0) & 0xff);               
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 8) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 16) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 24) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
      
    key = v.y;  
  
    ret += ((key >> 0) & 0xff);               
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 8) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 16) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 24) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
  
    key = v.z;  
  
    ret += ((key >> 0) & 0xff);               
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 8) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 16) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 24) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
  
    key = v.w;  
  
    ret += ((key >> 0) & 0xff);               
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 8) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 16) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
    ret += ((key >> 24) & 0xff);  
    ret += ret << 10;  
    ret ^= ret >> 6;  
  
  
    ret += ret << 3;  
    ret ^= ret >> 11;  
    ret += ret << 15;  
  
    return ret;  
}  
  
float jenkins(float4 v)  
{  
    return jenkins_orig(v) * 2.3283064365386962890625e-10;  
}  

1997 年に Bob Jenkins 氏が開発したハッシュ関数です。 *5
上記の文献での one_at_a_time 関数がそれです。

見た目上は綺麗なホワイトノイズになっている気がしますね。

key value
Instruction Mix 93
GPU Duration 27.65 μs
FPS 2721
PractRand Failed 221

しかし、テストには失敗しています。
こういう目視では確認できないような品質がチェックできるのが PractRand の強みです。

Blum Blum Shub

uint bbs65521_orig(uint value)  
{  
    value %= 65521;  
    value = (value * value) % 65521;  
    value = (value * value) % 65521;  
    return value;  
}  
  
float bbs65521(float4 v)  
{  
    return bbs65521_orig(bbs65521_orig(bbs65521_orig(bbs65521_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w)) * (1.0 / 65521);  
}  

Blum-Blum-Shub は、 1986 年に発表された暗号論的擬似乱数生成器です。 *6

x_{n+1} = x_n^{2} \mod{M} の漸化式で更新されます。
今回は M = 65521 として、品質向上のために 2 回処理してから返しています。

本来は M に大きな素数 p, q を用いて M = p q となるように構成するのですが、今回は小型版のようなものなので「暗号論的」な強度はありません。

見た目上の違和感はありませんね。

key value
Instruction Mix 53
GPU Duration 27.65 μs
FPS 2735
PractRand Failed 216

でもテストには落ちています。

加えて、定数を 4093 に変えてやってみたバージョンを以下に示します。


なぜ 4093 なのかというと、 float 型の精度が 24 bit 分しかないためです。
2 つの数を乗算したときに 24 bit をオーバーしないためには、法 (mod の部分) がその平方根 (\sqrt{2^{24}} = 2^{12}) 以下になるようにしなければなりません。
その条件下で最も大きい素数が 4093 だからです。

float bbs4093_orig(float seed)  
{  
    float prime24 = 4093;  
    float s = frac(seed / prime24);  
    s = frac(s * s * prime24);  
    s = frac(s * s * prime24);  
    return s;  
}  
  
float bbs4093(float4 v)  
{  
    return bbs4093_orig(v.x + bbs4093_orig(v.y + bbs4093_orig(v.z + bbs4093_orig(v.w))));  
}  

これも特にパターンは見受けられない気がします。
ただ、動画として見ると左側に線のようなものが見受けられました。

key value
Instruction Mix 49
GPU Duration 27.65 μs
FPS 2667
PractRand Failed 216

これもテストにはあっという間に落ちています。

CityHash

uint mur(uint a, uint h)  
{  
    a *= 0xcc9e2d51;  
    a = (a >> 17) | (a << 15);  
    a *= 0x1b873593;  
    h ^= a;  
    h = (h >> 19) | (h << 13);  
    return h * 5 + 0xe6546b64;  
}  
  
uint fmix(uint h)  
{  
    h ^= h >> 16;  
    h *= 0x85ebca6b;  
    h ^= h >> 13;  
    h *= 0xc2b2ae35;  
    h ^= h >> 16;  
    return h;  
}  
  
uint city_orig(uint4 s)  
{  
    uint len = 16;  
  
    uint a = s.y;  
    uint b = s.y;  
    uint c = s.z;  
    uint d = s.z;  
    uint e = s.x;  
    uint f = s.w;  
    uint h = len;  
  
    return fmix(mur(f, mur(e, mur(d, mur(c, mur(b, mur(a, h)))))));  
}  
  
float city(float4 s)  
{  
    return city_orig(uint4(s)) * 2.3283064365386962890625e-10;  
}  

CityHash は、 2011 年に google が開発した非暗号論的ハッシュ関数です。 *7

今回は、その中でも 32 bit のハッシュ値を返す CityHash32 を使います。

パターンは特に見受けられませんね。

key value
Instruction Mix 49
GPU Duration 27.65 μs
FPS 2695
PractRand Failed 241

ここにきてようやく TiB の壁 ( 2^{40} ) を突破した擬似乱数生成器 (ハッシュ?) が現れました。
さすが本業のハッシュ関数、品質が違いますね。

ESGTSA

uint esgtsa_orig(uint s)  
{  
    s = (s ^ 2747636419) * 2654435769;  
    s = (s ^ s >> 16) * 2654435769;  
    s = (s ^ s >> 16) * 2654435769;  
    return s;  
}  
  
float esgtsa(float4 v)  
{  
    return (esgtsa_orig(esgtsa_orig(esgtsa_orig(esgtsa_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w))) * 2.3283064365386962890625e-10;  
}  

ESGTSA は、 "Evolving sub-grid turbulence for smoke animation" という論文で提案されているアルゴリズムです。 *8
論文タイトルの頭文字をとっているわけですね。
もともとは、自然な煙のアニメーションを生成するための論文です。

keijiro 大先生によれば 、 Unity の HDRP でもこの実装が使われているそうです。

これもパターンは見受けられませんね。

key value
Instruction Mix 38
GPU Duration 26.62 μs
FPS 2721
PractRand Failed 240

1 TiB のテストでちょうど失敗しましたが、概ね高品質といえるでしょう。シンプルめの実装なので意外でした。

Fast

float fast_orig(float2 v)  
{  
    v = (1.0 / 4320.0) * v + float2(0.25, 0.0);  
    float state = frac(dot(v * v, float2(3571, 3571)));  
    return frac(state * state * (3571.0 * 2.0));  
}  
  
float fast(float4 v)  
{  
    return fast_orig(v.xy + v.zw);  
}  

Unreal Engine が典拠らしいです。
私にはアクセス権がないので確認できませんでした……

float 型だけで (uint 型を経由せずに) 計算できるのがポイントでしょうか。

レンズの干渉模様みたいなものが見えますね。

key value
Instruction Mix 16
GPU Duration 27.65 μs
FPS 2664
PractRand Failed 216

テストには即座に落ちてしまいました。

Hash without Sine

float hashwosine(float4 p)  
{  
    p = frac(p * float4(0.1031, 0.1030, 0.0973, 0.1099));  
    p += dot(p, p.wzxy + 33.33);  
    return frac((p.x + p.y) * (p.z + p.w));  
}  

この関数は、 2014 年に Dave_Hoskins 氏が Shadertoy 上で発表したものです。 *9

sin 関数が環境依存で値が変わる問題 (後述) に対処すべく、 sin なしで計算できることを念頭に設計されているようです。

微妙に縞模様が見えるような、そうでもないような……

key value
Instruction Mix 31
GPU Duration 28.67 μs
FPS 2702
PractRand Failed 216

テストには即座に落ちてしまいました。

license // Hash without Sine
// MIT License...
/ Copyright (c)2014 David Hoskins.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
/

HybridTaus

uint taus(uint z, int s1, int s2, int s3, uint m)  
{  
    uint b = ((z << s1) ^ z) >> s2;  
    return ((z & m) << s3) ^ b;  
}  
  
uint hybridtaus_orig(uint4 z)  
{  
    z.x = taus(z.x, 13, 19, 12, 0xfffffffe);  
    z.y = taus(z.y,  2, 25,  4, 0xfffffff8);  
    z.z = taus(z.z,  3, 11, 17, 0xfffffff0);  
    z.w = z.w * 1664525 + 1013904223;  
  
    return z.x ^ z.y ^ z.z ^ z.w;  
}  
  
float hybridtaus(float4 s)  
{  
    return hybridtaus_orig(uint4(s)) * 2.3283064365386962890625e-10;  
}  

HybridTaus は、 2007 年に Howes らによって開発されました。 *10

Hybrid とあるように、 Tausworthe Generator (LFSR; メルセンヌツイスタなどの祖先) と LCG (線形合同法) のハイブリッドとなっています。

何も映っていません。
というのも、 x, y, z 成分が小さい場合 (といっても 65536 以下とかそういう場合でも) 結果への寄与がほぼ 0 になるためです。
(最終的に float に変換する際は上位ビットが使われるが、 x, y, z がほぼ下位ビットにしか伝播しないためです。)

key value
Instruction Mix 25
GPU Duration 27.65 μs
FPS 2649
PractRand Failed ×

論外なのでテストしていません。
あくまでも「内部状態を更新していく擬似乱数生成器」としての運用を主眼としているものなので、ハッシュ用途には難しかったようです。

Interleaved Gradient Noise

float ign_orig(float2 v)  
{  
    float3 magic = float3(0.06711056, 0.00583715, 52.9829189);  
    return frac(magic.z * frac(dot(v, magic.xy)));  
}  
  
float ign(float4 v)  
{  
    return ign_orig(v.xy + v.zw);  
}  

Interleaved Gradient Noise は、 2014 年に Jorge Jimenez 氏によって発表されたノイズです。 *11

ディザと乱数の中間のような性質を持っており、 Temporal Anti Aliasing (TAA) などに用いるとよい結果が得られるそうです。

乱数というよりディザっぽいですね。それはそう。

key value
Instruction Mix 10
GPU Duration 26.62 μs
FPS 2632
PractRand Failed 216

もちろんテストには落ちています。あくまでもディザ用であって乱数ではないということですかね。

IQ's Integer Hash 1

uint iqint1_orig(uint n)  
{  
    n ^= n << 13;  
    return n * (n * n * 15731 + 789221) + 1376312589;  
}  
  
float iqint1(float4 pos)  
{  
    return iqint1_orig(uint(pos.x) + iqint1_orig(uint(pos.y) + iqint1_orig(uint(pos.z) + iqint1_orig(uint(pos.w))))) * 2.3283064365386962890625e-10;  
}  

iq 氏が 2017 年に Shadertoy 上で公開したハッシュ関数です。 *12

ちなみに、オリジナルのソースコードには以下のように記されています。

Do NOT use this hash as a random number generator.

特にパターンは見られず、概ね良好ですね。

key value
Instruction Mix 30
GPU Duration 28.67 μs
FPS 2630
PractRand Failed 217

しかしテストには落ちてしまっています。

license // The MIT License
// Copyright © 2017 Inigo Quilez
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

IQ's Integer Hash 2

uint3 iqint2_orig(uint3 x)  
{  
    uint k = 1103515245;  
  
    x = ((x >> 8) ^ x.yzx) * k;  
    x = ((x >> 8) ^ x.yzx) * k;  
    x = ((x >> 8) ^ x.yzx) * k;  
      
    return x;  
}  
  
float iqint2(float4 pos)  
{  
    return (dot(iqint2_orig(pos.xyz), 1) + dot(iqint2_orig(pos.w), 1)) * 2.3283064365386962890625e-10;  
}  

同じく、 iq 氏が 2017 年に Shadertoy 上で公開したハッシュ関数です。 *13

こちらも特にパターンは見られませんね。優秀そうです。

key value
Instruction Mix 42
GPU Duration 26.62 μs
FPS 2622
PractRand Failed 242

実際にテストにも通っています。

license // The MIT License
// Copyright © 2017 Inigo Quilez
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

IQ's Integer Hash 3

uint iqint3_orig(uint2 x)  
{  
    uint2 q = 1103515245 * ((x >> 1) ^ x.yx);  
    uint n = 1103515245 * (q.x ^ (q.y >> 3));  
  
    return n;  
}  
  
float iqint3(float4 pos)  
{  
    uint value = iqint3_orig(pos.xy) + iqint3_orig(pos.zw);  
    return value * 2.3283064365386962890625e-10;  
}  

これも同じく、 iq 氏が 2017 年に Shadertoy 上で公開したハッシュ関数です。 *14

これもぱっと見はパターンがなく、よさそうですね。

key value
Instruction Mix 24
GPU Duration 27.65 μs
FPS 2580
PractRand Failed 216

しかし、テストには即座に落ちてしまいました。違いがわからない……

ただし、 2024 年に更新されたらしく、現在は以下のアルゴリズムに置き換わっています。
従来のアルゴリズムは https://www.shadertoy.com/view/XlGcRh から参照することができます。

uint iqint32_orig(uint2 p)  
{  
    p *= uint2(73333, 7777);  
    p ^= uint2(3333777777, 3333777777) >> (p >> 28);  
    uint n = p.x * p.y;  
    return n ^ n >> 15;  
}  
  
float iqint32(float4 pos)  
{  
    uint value = iqint32_orig(pos.xy) + iqint32_orig(pos.zw);  
    return value * 2.3283064365386962890625e-10;  
}  

これもぱっと見は大丈夫そうですが……

key value
Instruction Mix 34
GPU Duration 27.65 μs
FPS 2728
PractRand Failed 218

これもテストには落ちてしまいました。改良前に比べれば長生きしているので、改良はされているようですね。

license // The MIT License
// Copyright © 2017,2024 Inigo Quilez
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

JKISS32

uint jkiss32_orig(uint2 p)  
{  
    uint x = p.x, y = p.y;  
    uint z = 345678912, w = 456789123, c = 0;  
    int t;  
  
    y ^= y << 5;  
    y ^= y >> 7;  
    y ^= y << 22;  
  
    t = int(z + w + c);  
    z = w;  
    c = uint(t < 0);  
    w = uint(t & 2147483647);  
    x += 1411392427;  
  
    return x + y + w;  
}  
  
float jkiss32(float4 p)   
{  
    return (jkiss32_orig(p.xy) + jkiss32_orig(p.zw)) * 2.3283064365386962890625e-10;  
}  

JKISS32 は、 2010 年に David Jones 氏が公開した乱数のベストプラクティス集に載っているアルゴリズムです。 *15

もともと George Marsaglia 大先生の KISS 擬似乱数生成器 *16 があって、それを改良したものだそうです。

乗算を使わずに計算できる利点があるとのことです。

なお、もともとは x, y, z, w, c が内部状態 (state) となっていたものをシェーダー用に改造した模様です。

見るからにダメです。
x がほぼ素通しになる設計上、横縞になってしまうようです。

key value
Instruction Mix 15
GPU Duration 26.62 μs
FPS 2687
PractRand Failed ×

論外なのでテストはしていません。

LCG

uint lcg_orig(uint p)  
{  
    return p * 1664525 + 1013904223;  
}  
  
float lcg(float4 v)  
{  
    return (lcg_orig(lcg_orig(lcg_orig(lcg_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w))) * 2.3283064365386962890625e-10;  
}  

言わずと知れた線形合同法です。この定数は Numerical Recipes によるものです。 *17

はっきりとパターンが見えますね。

key value
Instruction Mix 14
GPU Duration 27.65 μs
FPS 2695
PractRand Failed 216

当然テストにも即座に落ちています。

MD5

uint F(uint3 v)  
{  
    return (v.x & v.y) | (~v.x & v.z);  
}  
  
uint G(uint3 v)  
{  
    return (v.x & v.z) | (v.y & ~v.z);  
}  
  
uint H(uint3 v)  
{  
    return v.x ^ v.y ^ v.z;  
}  
  
uint I(uint3 v)  
{  
    return v.y ^ (v.x | ~v.z);  
}  
  
void FF(inout uint4 v, inout uint4 rotate, uint x, uint ac)  
{  
    v.x = v.y + rotl(v.x + F(v.yzw) + x + ac, rotate.x);  
    rotate = rotate.yzwx;  
    v = v.yzwx;  
}  
  
void GG(inout uint4 v, inout uint4 rotate, uint x, uint ac)  
{  
    v.x = v.y + rotl(v.x + G(v.yzw) + x + ac, rotate.x);  
    rotate = rotate.yzwx;  
    v = v.yzwx;  
}  
  
void HH(inout uint4 v, inout uint4 rotate, uint x, uint ac)  
{  
    v.x = v.y + rotl(v.x + H(v.yzw) + x + ac, rotate.x);  
    rotate = rotate.yzwx;  
    v = v.yzwx;  
}  
  
void II(inout uint4 v, inout uint4 rotate, uint x, uint ac)  
{  
    v.x = v.y + rotl(v.x + I(v.yzw) + x + ac, rotate.x);  
    rotate = rotate.yzwx;  
    v = v.yzwx;  
}  
  
uint K(uint i)  
{  
    return uint(abs(sin(float(i) + 1.0)) * float(0xffffffff));  
}  
  
uint4 md5_orig(uint4 u)  
{  
    uint4 digest = uint4(0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476);  
    uint4 r, v = digest;  
    uint i = 0;  
  
    uint m[16];  
  
    m[0] = u.x;  
    m[1] = u.y;  
    m[2] = u.z;  
    m[3] = u.w;  
    m[4] = 0;  
    m[5] = 0;  
    m[6] = 0;  
    m[7] = 0;  
    m[8] = 0;  
    m[9] = 0;  
    m[10] = 0;  
    m[11] = 0;  
    m[12] = 0;  
    m[13] = 0;  
    m[14] = 0;  
    m[15] = 0;  
  
    r = uint4(7, 12, 17, 22);  
  
    FF(v, r, m[0], K(i++));  
    FF(v, r, m[1], K(i++));  
    FF(v, r, m[2], K(i++));  
    FF(v, r, m[3], K(i++));  
    FF(v, r, m[4], K(i++));  
    FF(v, r, m[5], K(i++));  
    FF(v, r, m[6], K(i++));  
    FF(v, r, m[7], K(i++));  
    FF(v, r, m[8], K(i++));  
    FF(v, r, m[9], K(i++));  
    FF(v, r, m[10], K(i++));  
    FF(v, r, m[11], K(i++));  
    FF(v, r, m[12], K(i++));  
    FF(v, r, m[13], K(i++));  
    FF(v, r, m[14], K(i++));  
    FF(v, r, m[15], K(i++));  
  
    r = uint4(5, 9, 14, 20);  
    GG(v, r, m[1], K(i++));  
    GG(v, r, m[6], K(i++));  
    GG(v, r, m[11], K(i++));  
    GG(v, r, m[0], K(i++));  
    GG(v, r, m[5], K(i++));  
    GG(v, r, m[10], K(i++));  
    GG(v, r, m[15], K(i++));  
    GG(v, r, m[4], K(i++));  
    GG(v, r, m[9], K(i++));  
    GG(v, r, m[14], K(i++));  
    GG(v, r, m[3], K(i++));  
    GG(v, r, m[8], K(i++));  
    GG(v, r, m[13], K(i++));  
    GG(v, r, m[2], K(i++));  
    GG(v, r, m[7], K(i++));  
    GG(v, r, m[12], K(i++));  
  
    r = uint4(4, 11, 16, 23);  
    HH(v, r, m[5], K(i++));  
    HH(v, r, m[8], K(i++));  
    HH(v, r, m[11], K(i++));  
    HH(v, r, m[14], K(i++));  
    HH(v, r, m[1], K(i++));  
    HH(v, r, m[4], K(i++));  
    HH(v, r, m[7], K(i++));  
    HH(v, r, m[10], K(i++));  
    HH(v, r, m[13], K(i++));  
    HH(v, r, m[0], K(i++));  
    HH(v, r, m[3], K(i++));  
    HH(v, r, m[6], K(i++));  
    HH(v, r, m[9], K(i++));  
    HH(v, r, m[12], K(i++));  
    HH(v, r, m[15], K(i++));  
    HH(v, r, m[2], K(i++));  
  
    r = uint4(6, 10, 15, 21);  
    II(v, r, m[0], K(i++));  
    II(v, r, m[7], K(i++));  
    II(v, r, m[14], K(i++));  
    II(v, r, m[5], K(i++));  
    II(v, r, m[12], K(i++));  
    II(v, r, m[3], K(i++));  
    II(v, r, m[10], K(i++));  
    II(v, r, m[1], K(i++));  
    II(v, r, m[8], K(i++));  
    II(v, r, m[15], K(i++));  
    II(v, r, m[6], K(i++));  
    II(v, r, m[13], K(i++));  
    II(v, r, m[4], K(i++));  
    II(v, r, m[11], K(i++));  
    II(v, r, m[2], K(i++));  
    II(v, r, m[9], K(i++));  
  
    return digest + v;  
}  
  
float md5(float4 v)  
{  
    uint4 result = md5_orig(v);  
    return dot(result, 1) * 2.3283064365386962890625e-10;  
}  

暗号論的ハッシュ関数である (と現在言っていいかどうかは諸説ありますが) MD5 *18 をシェーダー用に改造したものです。 *19

さすがに大丈夫そうです。綺麗なホワイトノイズですね。

key value
Instruction Mix 227
GPU Duration 78.85 μs
FPS 2748
PractRand Failed > 238

もちろん、統計的な検定はパスできます。
ただしさすがに重く、めちゃくちゃ時間がかかりそうだったので途中で止めてしまいました。
腐っても暗号論的ハッシュ関数なので大丈夫だと思います。多分。

MurmurHash3

uint fmix32(uint h)  
{  
    h ^= h >> 16;  
    h *= 0x85ebca6b;  
    h ^= h >> 13;  
    h *= 0xc2b2ae35;  
    h ^= h >> 16;  
    return h;  
}  
  
uint murmur34_orig(uint4 seed)  
{  
    uint c1 = 0xcc9e2d51, c2 = 0x1b873593;  
    uint h = 0;  
    uint k = seed.x;  
  
    k *= c1;  
    k = rotl(k, 15);  
    k *= c2;  
  
    h ^= k;  
    h = rotl(h, 13);  
    h = h * 5 + 0xe6546b64;  
  
    k = seed.y;  
  
    k *= c1;  
    k = rotl(k, 15);  
    k *= c2;  
  
    h ^= k;  
    h = rotl(h, 13);  
    h = h * 5 + 0xe6546b64;  
  
    k = seed.z;  
  
    k *= c1;  
    k = rotl(k, 15);  
    k *= c2;  
  
    h ^= k;  
    h = rotl(h, 13);  
    h = h * 5 + 0xe6546b64;  
  
    k = seed.w;  
  
    k *= c1;  
    k = rotl(k, 15);  
    k *= c2;  
  
    h ^= k;  
    h = rotl(h, 13);  
    h = h * 5 + 0xe6546b64;  
  
    h ^= 16;  
    return fmix32(h);  
}  
  
float murmur34(float4 v)  
{  
    return murmur34_orig(v) * 2.3283064365386962890625e-10;  
}  

MurmurHash3 は、ハッシュ関数のテストスイートである SMHasher 内に実装されているハッシュ関数です。
暗号論的ではないですが、そのぶん速度的にも品質的にも性能が良いことで有名ですね。

今回は、 32bit ベースの実装である MurmurHash3_x86_32 を使います。

大丈夫そうですね。綺麗なホワイトノイズです。

key value
Instruction Mix 43
GPU Duration 39.94 μs
FPS 2683
PractRand Failed 241

テストも問題なく、 TiB の壁を突破しました。

PCG

uint pcg_orig(uint v)  
{  
    uint state = v * 747796405 + 2891336453;  
    uint word = ((state >> ((state >> 28) + 4)) ^ state) * 277803737;  
    return word ^ word >> 22;  
}  
  
float pcg(float4 v)  
{  
    return pcg_orig(pcg_orig(pcg_orig(pcg_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w)) * 2.3283064365386962890625e-10;  
}  

PCG は、 2014 年に O'neill 氏によって開発された擬似乱数生成器です。 *20

簡単に PCG について説明すると、かの有名な線形合同法に特殊な出力関数をかませることで品質を向上させつつ、内部理論が明らかな線形合同法のメリット (ジャンプが使えるなど) も得ることができるという一挙両得な擬似乱数生成器です。

PCG にはバリアントがたくさんありますが、中でも pcg_oneseq_32_rxs_m_xs_32_random_r を使います。
要するに、

  • 内部状態 32 bit
  • 単一シーケンス
  • 出力関数が rxs_m_xs_32
    • rightshift - xorshift - multiply - xorshift 32bit の意

という意味です。

key value
Instruction Mix 38
GPU Duration 29.70 μs
FPS 2704
PractRand Failed 238

テストには失敗していますが、大健闘しています。
これも本業は擬似乱数生成器なのですごいです。
出力関数に重きを置いているのが功を奏している感じですね。

PCG2D

uint2 pcg2d_orig(uint2 v)  
{  
    v = v * 1664525 + 1013904223;  
  
    v.x += v.y * 1664525;  
    v.y += v.x * 1664525;  
  
    v = v ^ v >> 16;  
  
    v.x += v.y * 1664525;  
    v.y += v.x * 1664525;  
  
    v = v ^ v >> 16;  
  
    return v;  
}  
  
float pcg2d(float4 v)  
{  
    return (dot(pcg2d_orig(uint2(v.xy)), 1) + dot(pcg2d_orig(uint2(v.zw)), 1)) * 2.3283064365386962890625e-10;  
}  

これは、 2020 年に Mark Jarzynski 氏らが設計したハッシュ関数です。 *21
名前の通り PCG を設計のコンセプトとしていますが、その実態は大きく異なり、ハッシュ関数となっています。

key value
Instruction Mix 37
GPU Duration 27.65 μs
FPS 2664
PractRand Failed 227

見た目は大丈夫そうですが、テストには失敗しています。

PCG3D

uint3 pcg3d_orig(uint3 v)  
{  
    v = v * 1664525 + 1013904223;  
  
    v.x += v.y * v.z;  
    v.y += v.z * v.x;  
    v.z += v.x * v.y;  
  
    v = v ^ v >> 16;  
  
    v.x += v.y * v.z;  
    v.y += v.z * v.x;  
    v.z += v.x * v.y;  
  
    return v;  
}  
  
float pcg3d(float4 v)  
{  
    return (dot(pcg3d_orig(v.xyz), 1) + dot(pcg3d_orig(v.w), 1)) * 2.3283064365386962890625e-10;  
}  

これも同じく、 PCG にインスパイアされて設計されたハッシュ関数です。
Unreal Engine が典拠らしいです。
3 入力 3 出力しかなかったので、無理やり 4 入力 1 出力に拡張しました。

key value
Instruction Mix 38
GPU Duration 28.67 μs
FPS 2700
PractRand Failed 242

比較的軽量で、テストに通っています。

PCG3D16

uint3 pcg3d16_orig(uint3 v)  
{  
    v = v * 12829 + 47989;  
  
    v.x += v.y * v.z;  
    v.y += v.z * v.x;  
    v.z += v.x * v.y;  
  
    v.x += v.y * v.z;  
    v.y += v.z * v.x;  
    v.z += v.x * v.y;  
  
    v >>= 16;  
  
    return v;  
}  
  
float pcg3d16(float4 v)  
{  
    uint3 a = pcg3d16_orig(v.xyz);  
    uint3 b = pcg3d16_orig(float3(v.w, 0, 0));  
      
    return ((dot(a, 1) + dot(b, 1)) & 65535) * 1.52587890625e-5;  
}  

PCG3D の線形合同法の定数を 16 ビットにして、出力も 16 ビットに絞ったバージョンです。
これも 3 入力 3 出力しかなかったので無理矢理拡張しました。

key value
Instruction Mix 30
GPU Duration 27.65 μs
FPS 2706
PractRand Failed 225

見た目は問題なさそうですが、テスト結果によれば品質は落ちてしまっています。

PCG4D

uint4 pcg4d_orig(uint4 v)  
{  
    v = v * 1664525 + 1013904223;  
  
    v.x += v.y * v.w;  
    v.y += v.z * v.x;  
    v.z += v.x * v.y;  
    v.w += v.y * v.z;  
  
    v = v ^ v >> 16;  
  
    v.x += v.y * v.w;  
    v.y += v.z * v.x;  
    v.z += v.x * v.y;  
    v.w += v.y * v.z;  
  
    return v;  
}  
  
float pcg4d(float4 v)  
{  
    uint4 a = pcg4d_orig(v);  
    return dot(a, 1) * 2.3283064365386962890625e-10;  
}  

これは、 PCG2D と同じく 2020 年に Mark Jarzynski 氏らが設計したハッシュ関数です。 *22

key value
Instruction Mix 29
GPU Duration 27.65 μs
FPS 2652
PractRand Failed 242

それなりに速く、かつ TiB の壁を超える頑健性も示しています。

Pseudo

float pseudo_orig(float2 v)  
{  
    v = frac(v / 128.0) * 128.0 + float2(-64.340622, -72.465622);  
    return frac(dot(v.xyx * v.xyy, float3(20.390625, 60.703125, 2.4281209)));  
}  
  
float pseudo(float4 v)  
{  
    return pseudo_orig(v.xy + v.zw);  
}  

これも Unreal Engine が典拠らしいです。

key value
Instruction Mix 20
GPU Duration 27.65 μs
FPS 2605
PractRand Failed 216

出力にもパターンが見える気がしますね。
案の定テストにも落ちています。

Ranlim32

uint ranlim32_orig(uint j)  
{  
    uint u, v, w1, w2, x, y;  
  
    v = 2244614371;  
    w1 = 521288629;  
    w2 = 362436069;  
  
    u = j ^ v;  
  
    u = u * 2891336453 + 1640531513;  
    v ^= v >> 13;  
    v ^= v << 17;  
    v ^= v >> 5;  
    w1 = 33378 * (w1 & 0xffff) + (w1 >> 16);  
    w2 = 57225 * (w2 & 0xffff) + (w2 >> 16);  
  
    v = u;  
  
    u = u * 2891336453 + 1640531513;  
    v ^= v >> 13;  
    v ^= v << 17;  
    v ^= v >> 5;  
    w1 = 33378 * (w1 & 0xffff) + (w1 >> 16);  
    w2 = 57225 * (w2 & 0xffff) + (w2 >> 16);  
  
    x = u ^ u << 9;  
    x ^= x >> 17;  
    x ^= x << 6;  
    y = w1 ^ w1 << 17;  
    y ^= y >> 15;  
    y ^= y << 5;  
  
    return (x + v) ^ (y + w2);  
}  
  
float ranlim32(float4 v)  
{  
    return ranlim32_orig(ranlim32_orig(ranlim32_orig(ranlim32_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w)) * 2.3283064365386962890625e-10;  
}  

Ranlim32 は Numerical Recipes 3rd edition に記載されています。 *23

なんかとにかく全部乗せみたいな感じですね。線形合同法、 Xorshift などの要素が含まれています。

key value
Instruction Mix 79
GPU Duration 27.65 μs
FPS 2595
PractRand Failed 228

見た目上は問題ありませんが、テストに落ちています。
他のハッシュ関数と比べると結構重いので厳しいところ。

Superfast

uint superfast4_orig(uint4 data)  
{  
    uint hash = 8, tmp;  
  
    hash += data.x & 0xffff;  
    tmp = (((data.x >> 16) & 0xffff) << 11) ^ hash;  
    hash = hash << 16 ^ tmp;  
    hash += hash >> 11;  
  
    hash += data.y & 0xffff;  
    tmp = (((data.y >> 16) & 0xffff) << 11) ^ hash;  
    hash = hash << 16 ^ tmp;  
    hash += hash >> 11;  
  
    hash += data.z & 0xffff;  
    tmp = (((data.z >> 16) & 0xffff) << 11) ^ hash;  
    hash = hash << 16 ^ tmp;  
    hash += hash >> 11;  
  
    hash += data.w & 0xffff;  
    tmp = (((data.w >> 16) & 0xffff) << 11) ^ hash;  
    hash = hash << 16 ^ tmp;  
    hash += hash >> 11;  
  
    hash ^= hash << 3;  
    hash += hash >> 5;  
    hash ^= hash << 4;  
    hash += hash >> 17;  
    hash ^= hash << 25;  
    hash += hash >> 6;  
  
    return hash;  
}  
  
float superfast4(float4 v)  
{  
    return superfast4_orig(v) * 2.3283064365386962890625e-10;   
}  

Superfast は、 Paul Hsieh 氏によって開発されたハッシュ関数です。 *24

CPU においては CRC32 や FNV に比べて高速に実行できたらしいです。
現代においてはちょっと命令数が多めに見えますが果たして……?

key value
Instruction Mix 43
GPU Duration 26.62 μs
FPS 2636
PractRand Failed 219

見た目上は問題なさそうに見えますが、テストに落ちてしまっています。

TEA

uint2 scrambleTea(uint2 v, int rounds)  
{  
    uint y = v.x;  
    uint z = v.y;  
    uint sum = 0;  
  
    for (int i = 0; i < rounds; i++)  
    {  
        sum += 0x9e3779b9;  
        y += ((z << 4) + 0xa341316c) ^ (z + sum) ^ ((z >> 5) + 0xc8013ea4);  
        z += ((y << 4) + 0xad90777d) ^ (y + sum) ^ ((y >> 5) + 0x7e95761e);  
    }  
  
    return uint2(y, z);  
}  
  
float tea4(float4 v)  
{  
    return dot(scrambleTea(v.xy, 4) + scrambleTea(v.zw, 4), 1) * 2.3283064365386962890625e-10;   
}  

TEA (Tiny Encription Algorithm) は、軽量なブロック暗号アルゴリズムです。 *25

ラウンド数を指定できます。オリジナルでは 32 だったようですが、重すぎるので今回は 4 とします。

key value
Instruction Mix 87
GPU Duration 29.70 μs
FPS 2626
PractRand Failed 221

織物のような模様が見えますね。
テストにも落ちています。

Trig

float trig_orig(float2 pos)  
{  
    return frac(sin(dot(pos, float2(12.9898, 78.233))) * 43758.5453123);  
}  
  
float trig(float4 pos)  
{  
    return frac(sin(dot(pos, float4(12.9898, 78.233, 42.234, 25.3589))) * 43758.5453123);  
}  

有名なワンライナー乱数です。
名前はたぶん Trigonometric functions (三角関数) からきています。
あの The Book of Shaders にも載っています。

コードや定数でググれば多数の使用例が出てきます。例えば、 Unity の ShaderGraph の Random Range ノード でもこの実装が使われています。

利点としては、実装が簡単なことが挙げられます。
欠点としては、三角関数を利用しているため異なる環境下で再現性がないこと、案外わかりやすいパターンがあることが挙げられます。

key value
Instruction Mix 11
GPU Duration 27.65 μs
FPS 2639
PractRand Failed 216

特徴的な縞模様になっており、テストにも即座に落ちています。

Wang

uint wang_orig(uint v)  
{  
    v = (v ^ 61) ^ (v >> 16);  
    v *= 9;  
    v ^= v >> 4;  
    v *= 0x27d4eb2d;  
    v ^= v >> 15;  
    return v;  
}  
  
float wang(float4 v)  
{  
    return wang_orig(wang_orig(wang_orig(wang_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w))* 2.3283064365386962890625e-10;   
}  

Wang は、 Thomas Wang 氏が 1997 年に発表したハッシュ関数です。 *26

key value
Instruction Mix 41
GPU Duration 27.65 μs
FPS 2639
PractRand Failed 235

健闘しましたが、テスト結果は 1 TiB には届きませんでした。

Xorshift128

uint4 xorshift128_orig(uint4 v)  
{  
    v.w ^= v.w << 11;  
    v.w ^= v.w >> 8;  
    v = v.wxyz;  
    v.x ^= v.y;  
    v.x ^= v.y >> 19;  
  
    return v;  
}  
  
float xorshift128(float4 v)  
{  
    uint4 vv = xorshift128_orig(uint4(v));  
    return dot(vv, 1) * 2.3283064365386962890625e-10;  
}  

Xorshift128 は、 George Marsaglia 氏が 2003 年に発表した擬似乱数生成器です。 *27

本業は擬似乱数生成器なので、 v.y, v.z は v.x, v.y そのままになっているなど、ハッシュ関数にするには若干怪しいところがあります。

案の定でした。

key value
Instruction Mix 10
GPU Duration 26.62 μs
FPS 2601
PractRand Failed ×

論外なのでテストしていません。

Xorshift32

uint xorshift32_orig(uint v)  
{  
    v ^= v << 13;  
    v ^= v >> 17;  
    v ^= v << 5;  
    return v;  
}  
  
float xorshift32(float4 v)  
{  
    return xorshift32_orig(xorshift32_orig(xorshift32_orig(xorshift32_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w)) * 2.3283064365386962890625e-10;  
}  

Xorshift32 は、同じく George Marsaglia 氏が 2003 年に発表した擬似乱数生成器です。 *28
これは半分ハッシュ関数のような設計になっているので、 xorshift128 よりはいい成績になりそうですが……

key value
Instruction Mix 33
GPU Duration 30.72 μs
FPS 2601
PractRand Failed 216

さざ波のような模様がありますね。
残念ながらテストにも即座に落ちてしまっています。

xxHash32

uint xxhash_orig(uint4 value)  
{  
    uint XXH_PRIME32_1 = 0x9E3779B1;  
    uint XXH_PRIME32_2 = 0x85EBCA77;  
    uint XXH_PRIME32_3 = 0xC2B2AE3D;  
  
    uint4 state = uint4(XXH_PRIME32_1 + XXH_PRIME32_2, XXH_PRIME32_2, 0, -XXH_PRIME32_1);  
  
    state += value * XXH_PRIME32_2;  
    state = rotl(state, 13);  
    state *= XXH_PRIME32_1;  
  
    uint h32 = rotl(state[0], 1) + rotl(state[1], 7) + rotl(state[2], 12) + rotl(state[3], 18);  
    h32 += 16;  
  
    h32 ^= h32 >> 15;  
    h32 *= XXH_PRIME32_2;  
    h32 ^= h32 >> 13;  
    h32 *= XXH_PRIME32_3;  
    h32 ^= h32 >> 16;  
  
    return h32;  
}  
  
float xxhash(float4 v)  
{  
    return xxhash_orig(v) * 2.3283064365386962890625e-10;   
}  

xxHash は、軽量な非暗号論的ハッシュ関数です。 *29

今回はその中でも 32bit ベースの xxHash32 を用います。

key value
Instruction Mix 42
GPU Duration 27.65 μs
FPS 2676
PractRand Failed 227

見た目上は問題なさそうですが、テストには落ちてしまいました。意外です。

license xxHash Library
Copyright (c) 2012-2021 Yann Collet
All rights reserved.

BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php)

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

  • Redistributions of source code must retain the above copyright notice, this
    list of conditions and the following disclaimer.

  • Redistributions in binary form must reproduce the above copyright notice, this
    list of conditions and the following disclaimer in the documentation and/or
    other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Wyhash

uint2 mul64(uint a, uint b)  
{  
    uint alo = a & 0xffff, ahi = a >> 16;  
    uint blo = b & 0xffff, bhi = b >> 16;  
  
    uint lo = alo * blo;  
    uint mid1 = ahi * blo;  
    uint mid2 = alo * bhi;  
    uint hi = ahi * bhi;  
  
    return uint2(  
        lo + (mid1 << 16) + (mid2 << 16),  
        hi + (mid1 >> 16) + (mid2 >> 16) + (((mid1 & 0xffff) + (mid2 & 0xffff) + (lo >> 16)) >> 16));  
}  
  
void wymix32(inout uint a, inout uint b)  
{  
    uint2 c = uint2(a ^ 0x53c5ca59u, 0);  
    c = mul64(c.x, b ^ 0x74743c1b);  
  
    a = c.x;  
    b = c.y;  
}  
  
uint wyhash_orig(float4 value, uint seed)  
{  
    uint see1 = 16;  
    wymix32(seed, see1);  
      
    seed ^= uint(value.x);  
    see1 ^= uint(value.y);  
    wymix32(seed, see1);  
      
    seed ^= uint(value.z);  
    see1 ^= uint(value.w);  
    wymix32(seed, see1);  
      
    wymix32(seed, see1);  
    wymix32(seed, see1);  
    return seed ^ see1;  
}  
  
float wyhash(float4 v)  
{  
    return wyhash_orig(v, 0xa0b428db) * 2.3283064365386962890625e-10;  
}  

Wyhash は、 wangyi-fudan 氏によって 2019 年に開発された高速なハッシュ関数です。 *30
2 倍長乗算 (32bit x 32bit -> 64bit) をうまく使っているハッシュ関数です。
CPU ならこの乗算は専用命令があってすぐできるのですが、シェーダー上では intrinsics が存在しないので筆算法で解きました。


なお、同じ GPU でも OpenGL (GLSL) には umulExtended() 関数があり、 2 倍長乗算が一発で計算できるらしいです。うらやましい。
HLSL では まだ検討段階らしい です。


2 倍長乗算をためしに Karatsuba 法 で実装してみたらむしろ遅くなった、という悲しい事件がありました。ここに供養しておきます。

// csharp karatsuba multiplication  
public static (uint lo, uint hi) mulhi(uint a, uint b)  
{  
    uint alo = a & 0xffff, ahi = a >> 16;  
    uint blo = b & 0xffff, bhi = b >> 16;  
  
    uint hihi = ahi * bhi;  
    uint lolo = alo * blo;  
  
    uint aa = alo - ahi;  
    uint bb = bhi - blo;  
    uint mid = aa * bb;  
    uint midSign = (mid != 0 && ((aa ^ bb) >> 31) != 0) ? 1u : 0u;  
  
    uint lo = lolo + (mid << 16) + (lolo << 16) + (hihi << 16);  
    uint carry = (lolo >> 16) + (mid & 0xffff) + (lolo & 0xffff) + (hihi & 0xffff);  
    uint hi = hihi + (mid >> 16) + (lolo >> 16) + (hihi >> 16) + (carry >> 16) - (midSign << 16);  
  
    return (lo, hi);  
}  

key value
Instruction Mix 87
GPU Duration 28.67 μs
FPS 2636
PractRand Failed 242

2 倍長乗算のせいか命令数は増えてしまっていますが、テストに問題なくパスしています。


なお、 wyhash には rapidhash という後継があるようですが、 64 x 64 = 128 bit の 2 倍長乗算が必要なためシェーダーで高効率に実装するのは難しそうです。

lowbias32

uint lowbias32_orig(uint x)  
{  
    x ^= x >> 16;  
    x *= 0x7feb352d;  
    x ^= x >> 15;  
    x *= 0x846ca68b;  
    x ^= x >> 16;  
    return x;  
}  
  
float lowbias32(float4 v)  
{  
    return lowbias32_orig(lowbias32_orig(lowbias32_orig(lowbias32_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w)) * 2.3283064365386962890625e-10;  
}  

これは、 Chris Wellons 氏によって 2018 年に発表されたハッシュ関数です。 *31
品質の良いハッシュを探索するツールによって発見されたそうです。

key value
Instruction Mix 41
GPU Duration 28.67 μs
FPS 2638
PractRand Failed 242

比較的高速で、テストにもパスしています。

triple32

uint triple32_orig(uint x)  
{  
    x ^= x >> 17;  
    x *= 0xed5ad4bb;  
    x ^= x >> 11;  
    x *= 0xac4c1b51;  
    x ^= x >> 15;  
    x *= 0x31848bab;  
    x ^= x >> 14;  
    return x;  
}  
  
float triple32(float4 v)  
{  
    return triple32_orig(triple32_orig(triple32_orig(triple32_orig(uint(v.x)) + uint(v.y)) + uint(v.z)) + uint(v.w)) * 2.3283064365386962890625e-10;  
}  

これも Chris Wellons 氏によって 2018 年に発表されたハッシュ関数です。 *32
lowbias32 に比べて命令数が増えているものの品質が向上しており、「ランダムな置換 (並べ替え) と統計的に区別できない」とまで言われています。

key value
Instruction Mix 53
GPU Duration 27.65 μs
FPS 2603
PractRand Failed 239

なぜか lowbias32 よりもテスト結果が悪くなっています。どうして?

fihash

float fihash_orig(float2 v)  
{  
    uint2 u = asint(v * float2(141421356, 2718281828));  
    return float((u.x ^ u.y) * 3141592653) * 2.3283064365386962890625e-10;  
}  
  
float fihash(float4 v)  
{  
    return fihash_orig(v.xy + v.zw);  
}  

2024 年に Lumi 氏によって作成されたハッシュ関数です。 *33

key value
Instruction Mix 9
GPU Duration 28.67 μs
FPS 2642
PractRand Failed 216

命令数がずば抜けて少ないです。それはそう。
見た目上は問題なさそうですが、テストには即座に落ちてしまっています。


ちなみに、 2018 年に James_Harnett 氏によってほぼ同じ構造のハッシュ関数が提案されていることを注記しておきます。
(シンプルな構造ゆえ、シンクロニシティ的に生まれたものかと思います。たぶん。)
https://www.shadertoy.com/view/MdcfDj

license // The MIT License
// Copyright © 2024 Giorgi Azmaipharashvili
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

fast32hash

float4 fast32hash_orig(float2 v)  
{  
    const float2 offset = float2(26, 161);  
    const float domain = 71;  
    const float someLargeFloat = 951.135664;  
  
    float4 p = float4(v, v + 1);  
    p = p - floor(p * (1.0 / domain)) * domain;  
    p += offset.xyxy;  
    p *= p;  
    return frac(p.xzxz * p.yyww * (1.0 / someLargeFloat));  
}  
  
float fast32hash(float4 x)  
{  
    return fast32hash_orig(x.xy + x.zw).x;  
}  

2011 年に Brian Sharpe 氏が発表したハッシュ関数です。 *34

uint を使わずに実装できるため、整数計算が遅い GPU でも高速に実行できることが期待されます。

key value
Instruction Mix 17
GPU Duration 28.67 μs
FPS 2653
PractRand Failed 216

明らかに繰り返しのパターンが見えますね。
テストにも即座に落ちてしまっています。

Philox

uint4 philox_round(uint2 key, uint4 ctr)  
{  
   uint2 lohi0 = mul64(ctr.x, 0xD2511F53);  
   uint2 lohi1 = mul64(ctr.z, 0xCD9E8D57);  
  
   return uint4(lohi1.y ^ ctr.y ^ key.x, lohi1.x, lohi0.y ^ ctr.w ^ key.y, lohi0.x);  
}  
  
uint2 philox_bumpkey(uint2 key)  
{  
    return key + uint2(0x9E3779B9, 0xBB67AE85);  
}  
  
uint4 philox_orig(uint2 key, uint4 ctr)  
{  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
    key = philox_bumpkey(key);  
    ctr = philox_round(key, ctr);  
     
    return ctr;  
}  
  
float philox(float4 v)  
{  
    uint4 u = v;  
    return philox_orig(uint2(0xf19cd101, 0x3d30), u).x * 2.3283064365386962890625e-10;  
}  

Philox は、 2011 年に発表された擬似乱数生成器です。 *35
なかでも、 32 bit 環境でスタンダードな Philox-4x32-10 を使用しました。数字は 4 個 x 32 bit - 10 ラウンドを表しています。

key value
Instruction Mix 294
GPU Duration 62.46 μs
FPS 2729
PractRand Failed 242

命令数が多い分、テストは問題なさそうです。
ちょっと重いのが難点かも。

AES-CTR

static const int AesSbox[256] = {  
    0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,  
    0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,  
    0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,  
    0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,  
    0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,  
    0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,  
    0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,  
    0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,  
    0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,  
    0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,  
    0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,  
    0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,  
    0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,  
    0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,  
    0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,  
    0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16,  
};  
  
uint aesctr_subword(uint x)  
{  
    return uint(AesSbox[x & 0xff]) ^  
        uint(AesSbox[(x >> 8) & 0xff]) << 8 ^  
        uint(AesSbox[(x >> 16) & 0xff]) << 16 ^  
        uint(AesSbox[x >> 24]) << 24;  
}  
  
uint4 aesctr_round(uint4 k, uint rcon)  
{  
    //uint keygenassist0 = aesctr_subword(k1);  
    //uint keygenassist1 = keygenassist0 >> 8 ^ keygenassist0 << 24 ^ rcon;  
    uint keygenassist2 = aesctr_subword(k.w);  
    uint keygenassist3 = keygenassist2 >> 8 ^ keygenassist2 << 24 ^ rcon;  
  
    uint4 t = uint4(  
        k.x,  
        k.y ^ k.x,  
        k.z ^ k.y ^ k.x,  
        k.w ^ k.z ^ k.y ^ k.x);  
  
    t ^= keygenassist3;  
      
    return t;  
}  
  
uint aesctr_multiply(uint x, uint y)  
{  
    uint result = 0;  
    for (uint mask = 1; mask < 256; mask <<= 1)  
    {  
        if (y & mask)  
        {  
            result ^= x;  
        }  
        x = x << 1 ^ ((x & 0x80) ? 0x1b : 0);  
    }  
    return result;  
}  
  
uint4 aesctr_mixcolumns(uint4 u)  
{  
    uint4 r;  
    r.x =   
        uint(aesctr_multiply(2, u.x & 0xff) ^ aesctr_multiply(3, ((u.x >> 8) & 0xff)) ^ ((u.x >> 16) & 0xff) ^ (u.x >> 24)) ^  
        uint((u.x & 0xff) ^ aesctr_multiply(2, (u.x >> 8) & 0xff) ^ aesctr_multiply(3, (u.x >> 16) & 0xff) ^ (u.x >> 24)) << 8 ^  
        uint((u.x & 0xff) ^ ((u.x >> 8) & 0xff) ^ aesctr_multiply(2, (u.x >> 16) & 0xff) ^ aesctr_multiply(3, (u.x >> 24))) << 16 ^  
        uint(aesctr_multiply(3, u.x & 0xff) ^ ((u.x >> 8) & 0xff) ^ ((u.x >> 16) & 0xff) ^ aesctr_multiply(2, (u.x >> 24))) << 24;  
    r.y =   
        uint(aesctr_multiply(2, u.y & 0xff) ^ aesctr_multiply(3, ((u.y >> 8) & 0xff)) ^ ((u.y >> 16) & 0xff) ^ (u.y >> 24)) ^  
        uint((u.y & 0xff) ^ aesctr_multiply(2, (u.y >> 8) & 0xff) ^ aesctr_multiply(3, (u.y >> 16) & 0xff) ^ (u.y >> 24)) << 8 ^  
        uint((u.y & 0xff) ^ ((u.y >> 8) & 0xff) ^ aesctr_multiply(2, (u.y >> 16) & 0xff) ^ aesctr_multiply(3, (u.y >> 24))) << 16 ^  
        uint(aesctr_multiply(3, u.y & 0xff) ^ ((u.y >> 8) & 0xff) ^ ((u.y >> 16) & 0xff) ^ aesctr_multiply(2, (u.y >> 24))) << 24;  
    r.z =   
        uint(aesctr_multiply(2, u.z & 0xff) ^ aesctr_multiply(3, ((u.z >> 8) & 0xff)) ^ ((u.z >> 16) & 0xff) ^ (u.z >> 24)) ^  
        uint((u.z & 0xff) ^ aesctr_multiply(2, (u.z >> 8) & 0xff) ^ aesctr_multiply(3, (u.z >> 16) & 0xff) ^ (u.z >> 24)) << 8 ^  
        uint((u.z & 0xff) ^ ((u.z >> 8) & 0xff) ^ aesctr_multiply(2, (u.z >> 16) & 0xff) ^ aesctr_multiply(3, (u.z >> 24))) << 16 ^  
        uint(aesctr_multiply(3, u.z & 0xff) ^ ((u.z >> 8) & 0xff) ^ ((u.z >> 16) & 0xff) ^ aesctr_multiply(2, (u.z >> 24))) << 24;  
    r.w =   
        uint(aesctr_multiply(2, u.w & 0xff) ^ aesctr_multiply(3, ((u.w >> 8) & 0xff)) ^ ((u.w >> 16) & 0xff) ^ (u.w >> 24)) ^  
        uint((u.w & 0xff) ^ aesctr_multiply(2, (u.w >> 8) & 0xff) ^ aesctr_multiply(3, (u.w >> 16) & 0xff) ^ (u.w >> 24)) << 8 ^  
        uint((u.w & 0xff) ^ ((u.w >> 8) & 0xff) ^ aesctr_multiply(2, (u.w >> 16) & 0xff) ^ aesctr_multiply(3, (u.w >> 24))) << 16 ^  
        uint(aesctr_multiply(3, u.w & 0xff) ^ ((u.w >> 8) & 0xff) ^ ((u.w >> 16) & 0xff) ^ aesctr_multiply(2, (u.w >> 24))) << 24;  
    return r;  
}  
  
uint aesctr_orig(uint4 x)  
{  
    uint4 seed[11];  
  
    seed[0] = x;  
  
    seed[ 1] = aesctr_round(seed[0], 0x01);  
    seed[ 2] = aesctr_round(seed[1], 0x02);  
    seed[ 3] = aesctr_round(seed[2], 0x04);  
    seed[ 4] = aesctr_round(seed[3], 0x08);  
    seed[ 5] = aesctr_round(seed[4], 0x10);  
    seed[ 6] = aesctr_round(seed[5], 0x20);  
    seed[ 7] = aesctr_round(seed[6], 0x40);  
    seed[ 8] = aesctr_round(seed[7], 0x80);  
    seed[ 9] = aesctr_round(seed[8], 0x1b);  
    seed[10] = aesctr_round(seed[9], 0x36);  
  
    uint4 ctr = uint4(1, 0, 0, 0);  
  
    uint4 t = ctr ^ seed[0];  
  
    for (int i = 1; i <= 9; i++)  
    {  
        uint4 u;  
  
        u.x = ((t.x >> 0) & 0xff) << 0 ^  
            ((t.y >> 8) & 0xff) << 8 ^  
            ((t.z >> 16) & 0xff) << 16 ^  
            ((t.w >> 24) & 0xff) << 24;  
        u.y = ((t.y >> 0) & 0xff) << 0 ^  
            ((t.z >> 8) & 0xff) << 8 ^  
            ((t.w >> 16) & 0xff) << 16 ^  
            ((t.x >> 24) & 0xff) << 24;  
        u.z = ((t.z >> 0) & 0xff) << 0 ^  
            ((t.w >> 8) & 0xff) << 8 ^  
            ((t.x >> 16) & 0xff) << 16 ^  
            ((t.y >> 24) & 0xff) << 24;  
        u.w = ((t.w >> 0) & 0xff) << 0 ^  
            ((t.x >> 8) & 0xff) << 8 ^  
            ((t.y >> 16) & 0xff) << 16 ^  
            ((t.z >> 24) & 0xff) << 24;  
  
        u.x = aesctr_subword(u.x);  
        u.y = aesctr_subword(u.y);  
        u.z = aesctr_subword(u.z);  
        u.w = aesctr_subword(u.w);  
  
        u = aesctr_mixcolumns(u);  
  
        t = u ^ seed[i];  
    }  
  
    if (++ctr.x == 0) {  
        if (++ctr.y == 0) {  
            if (++ctr.z == 0) {  
                ++ctr.w;  
            }  
        }  
    }  
  
    {  
        uint4 u;  
  
        u.x = ((t.x >> 0) & 0xff) << 0 ^  
            ((t.y >> 8) & 0xff) << 8 ^  
            ((t.z >> 16) & 0xff) << 16 ^  
            ((t.w >> 24) & 0xff) << 24;  
        u.y = ((t.y >> 0) & 0xff) << 0 ^  
            ((t.z >> 8) & 0xff) << 8 ^  
            ((t.w >> 16) & 0xff) << 16 ^  
            ((t.x >> 24) & 0xff) << 24;  
        u.z = ((t.z >> 0) & 0xff) << 0 ^  
            ((t.w >> 8) & 0xff) << 8 ^  
            ((t.x >> 16) & 0xff) << 16 ^  
            ((t.y >> 24) & 0xff) << 24;  
        u.w = ((t.w >> 0) & 0xff) << 0 ^  
            ((t.x >> 8) & 0xff) << 8 ^  
            ((t.y >> 16) & 0xff) << 16 ^  
            ((t.z >> 24) & 0xff) << 24;  
  
        u.x = aesctr_subword(u.x);  
        u.y = aesctr_subword(u.y);  
        u.z = aesctr_subword(u.z);  
        u.w = aesctr_subword(u.w);  
  
        t = u ^ seed[i];  
    }  
  
    return t;  
}  
  
float aesctr(float4 v)  
{  
    return aesctr_orig(uint4(asint(v.x), asint(v.y), asint(v.z), asint(v.w))) * 2.3283064365386962890625e-10f;  
}  

AES (Advanced Encryption Standard) は、 2001 年にアメリカで標準暗号として定められた共通鍵暗号アルゴリズムです。 *36

今回は CTR モードを利用して実装しています。
CPU 上なら AES-NI 専用命令が使えてそれなりに速いのですが、シェーダー上となると完全にソフトウェア実装なのでめちゃくちゃ時間がかかります。

綺麗なんですがめちゃくちゃ重いです。

key value
Instruction Mix 1021
GPU Duration 4970 μs
FPS 133
PractRand Failed > 235

あまりにも重い (64 GiB のテストに半日かかった) のでテストはここまでで諦めました。

heptaplex-collapse noise

// @ENDESGA 2023  
  
uint heptaplex_orig(uint x, uint y, uint z)  
{  
    x = ~(~x - y - z) * ~(x - ~y - z) * ~(x - y - ~z);  
    y = ~(~x - y - z) * ~(x - ~y - z) * ~(x - y - ~z);  
    z = x ^ y ^ (~(~x - y - z) * ~(x - ~y - z) * ~(x - y - ~z));  
    return z ^ ~(~z >> 16);  
}  
  
float heptaplex(float4 v)   
{  
    return (heptaplex_orig(v.x, v.y, v.z) + heptaplex_orig(v.w, 0, 0)) * 2.3283064365386962890625e-10;  
}  

heptaplex-collapse noise は、 2023 年に ENDESGA 氏が Shadertoy 上で発表したノイズです。 *37

ぱっと見は綺麗です。

key value
Instruction Mix 46
GPU Duration 28.67 μs
FPS 2557
PractRand Failed 219

テストは即死ではないものの、それなりの早さで落ちてしまいました。

IbukiHash

// IbukiHash by Andante  
// This work is marked with CC0 1.0. To view a copy of this license, visit https://creativecommons.org/publicdomain/zero/1.0/  
  
float ibuki(float4 v)  
{  
    const uint4 mult =   
        uint4(0xae3cc725, 0x9fe72885, 0xae36bfb5, 0x82c1fcad);  
  
    uint4 u = uint4(v);  
    u = u * mult;  
    u ^= u.wxyz ^ u >> 13;  
      
    uint r = dot(u, mult);  
  
    r ^= r >> 11;  
    r = (r * r) ^ r;  
          
    return r * 2.3283064365386962890625e-10;  
}  

見慣れない関数かと思いますが、それはそうです。 私が今作りました。
命令数 (Instruction Mix) がなるべく少なく、かつテストには 1 TiB まで通るように設計しました。

key value
Instruction Mix 26
GPU Duration 27.65 μs
FPS 2681
PractRand Failed 241

設計思想

IbukiHash の全ての行に意味があります。
興味がない方は読み飛ばしていただいて大丈夫です。

const uint4 mult =   
    uint4(0xae3cc725, 0x9fe72885, 0xae36bfb5, 0x82c1fcad);  

乗算の定数は、詳細は省きますがビットが分散しやすい値にする必要があります。
今回は分散の良い値を研究している論文から引用しました。 *38

絶対にこの定数である必要はないので変更することもできます。たとえば uint4 が返り値としてほしくなった場合に別の定数で計算するなど。
ただ、少なくとも最上位ビットが 1 でかつ最下位の 3 ビットが 0b101 で、それなりにビットが分散している (popcnt(mult) が 16 に近い奇数で、かつ 0x0000ffff みたいに固まっていない) 必要があります。

uint4 u = uint4(v);  

asint() ではなく単純なキャストにしているのは、下位ビットに意味のある値を集中させるためです。
今回引数に取るのは座標なので、どうせ \lbrack -2^{16}, 2^{16} \rbrack ぐらいの小さい範囲にしかなりません。そうなると、どこに意味のある情報 (ビット) を置くかが重要になります。
そして今後ビットを攪拌するにあたって、下位ビットに情報があったほうが好都合なためです。

asint() を使うと、 float 型の ビットパターン の都合上上位ビットに情報が集まりがち、下位ビットが 0 になりがちであまりよろしくなかったのです。
測定結果を見た限りでは asint() のほうが多少速い感はありましたが、品質が落ちる(早くテストに落ちる)傾向がありました。なので致し方ありません。

u = u * mult;  

u に定数を掛け、 2^{32} で割った余りを得ます。
掛け算はハッシュ関数のなかでも非常に重要な要素で、下位ビットの情報を上位ビットに幅広く伝播させることができます。
めちゃくちゃ簡単に言えば、上位ビットのハッシュとしての「品質」を大きく高めることができる演算です。

2^{n} を法としたとき、奇数定数との掛け算 x *= (a | 1) は全単射の操作です。
つまり、特定の値に偏ることがなく、まんべんなくビットを混ぜることができます。

ここで、 u の各要素にそれぞれ別の定数を掛けている理由は、同じ定数を掛けると u.x と u.y の値を交換しても同じ結果が得られるようになってしまい、 y = x を軸に対称となってしまうためです。


HLSL では uint4 どうしの掛け算などをまとめて行えるのでアセンブリもそうやって最適化されるのかと思いきや、少なくとも中間言語 (DXIL) レベルでは別々に計算したとき (u.x *= mult.x; u.y *= mult.y; ...) と同じような命令が発行されているようです。

u ^= u.wxyz ^ u >> 13;  

u に xorshift っぽいことをします。
まず u ^ u >> 13 ですが、乗算で品質が上がった上位ビットを下位ビットに右シフトで伝播させ、上も下も品質を向上させます。
加えて x ^= x >> a は乗算と同じく全単射の操作です。同じく特定の値に偏らずまんべんなくビットを混ぜられます。
シフト定数 13 は、 32 bit の半分 (16) より小さい最大の素数なので使っています。シフトを大きくすると下位ビットに伝播しやすくなるのですが、伝播させられる情報量自体は減ってしまうので、バランスよく選びました。

また、 u.wxyz とスウィズルした値を xor することによって、別のビットとの「絡み」を発生させます。今までは u.xyzw のそれぞれの要素の中でだけビットの情報伝播が行われていましたが、ここで他の要素と混ぜることでよりハッシュらしくします。

uint r = dot(u, mult);  

次に、内積をとります。言い換えれば、

uint r = u.x * mult.x + u.y * mult.y + u.z * mult.z + u.w * mult.w;  

です。乗算によって再度攪拌したのち、全要素を加算してひとつにまとめます。
ひとつにまとめることで今後の命令数を減らせます。
このまとめのタイミングが重要で、早すぎると xyzw の差別化が行えずにテストに落ち、遅すぎると命令数が増えて実行が遅くなります。

また、あえて dot() を使ったのは、命令の最適化が行えるからです。
シェーダーの中間言語である DXIL には、 32 bit 整数の乗算と加算を同時に行える mad 命令があります。 fma (fused multiply add) の整数版のようなイメージです。
dot() を使うと、この mad 命令を活用してこういう感じに展開してくれます。

; dot(u, mult)  
mul %1, u.x, mult.x        ; %1 = u.x * mult.x;  
mad %1, u.y, mult.y, %1    ; %1 = u.y * mult.y + %1;  
mad %1, u.z, mult.z, %1    ; %1 = u.z * mult.z + %1;  
mad %1, u.w, mult.w, %1    ; %1 = u.w * mult.w + %1;  

ふつうに乗算と加算で計算するとなぜか mad 命令にしてくれないので、 dot() を使って命令の短縮を図ります。

 r ^= r >> 11;  

また xorshift をして、 dot() で品質が向上した上位ビットの情報を下位ビットにも伝播させます。
ここのシフト定数 11 は、前回のシフト定数 13 と互いに素であることから選びました。
そのほうが品質が良くなる傾向があるようです。体感ですが……

r = (r * r) ^ r;  

まず、 r = (r * r) ^ (r | 1) は全単射の操作です。
詳しい原理は私は分かっていませんが少なくとも uint ( 2^{32} を法とした演算) の範囲では総当たりで全単射になっていることを確認しています。
これが奇数定数の乗算 r *= (a | 1); よりもビットの攪拌性能が良いという噂があり、実際にテスト結果も向上したことから採用しました。
じゃあなんで全部これにしなかったのかというと、 mul だけで済む奇数定数乗算に比べて mul, xor と 1 命令増えてしまうためです。最後の一番大事なところに使いました。

ところで、 r | 1 が抜けているじゃないかと思った方は正しいです。
ですがここでは抜けていてもよいのです。 or がなくなることで全単射操作ではなくなります (最下位ビットが必ず 0 になります) 。ですが、 float の精度は 24 bit ですので、 uint でつくった 32 ビットのうち下位 8 ビットの情報は切り捨てられます。したがって大した問題にはなりません。
1 命令ぶん早くするために理論も犠牲にするという手法 (?) です。

return r * 2.3283064365386962890625e-10;  

最後に、 2^{-32} を掛けて \lbrack 0, 1 ) の float に戻します。
ここで、代わりに asfloat(0x3f800000 | r >> 9) - 1; とする手法もあります。
asfloat() を使うほうは、要するに \lbrack 1.0, 2.0 ) にビットパターン変換して 1 を引くことで \lbrack 0, 1 ) に変換する手法です。 asfloat() 法のほうが速くなる場合があるらしいのですが、変換後の最下位ビットが必ず 0 になるため 23 bit 精度になってしまう問題があります。
対して、掛け算のほうでは 24 bit 精度を維持できます。そのため、掛け算を選択しました。


以上が設計のお話です。
いつもは CPU 上で擬似乱数生成器を設計してみたりしているのですが、 GPU (シェーダー) 上となると速くて使える関数や命令が違うので、勝手が違って楽しかったです。

まとめ

みんな大好き比較グラフのお時間です。
FPS が微妙にあてにならない感があったので、命令数で比較することにします。

縦軸が Instruction Mix (命令数; 少ないほうが速いと期待される) 、横軸が PractRand Failed (40 以上は合格とみなしてよい) です。
つまり、右下に行けば行くほど軽くて品質が良いことになります。

IbukiHash は合格したシェーダー乱数のなかでは命令数が一番少なく、軽くて強いことが分かります。
PCG4D もいい線ですね。

また、品質を気にせず速さだけを求めるなら fihash でしょうか。
テストには落ちたものの見た目上は問題なさそうだったので、活用できるかもしれません。

元データの表も貼っておきます。
PractRand Failed → Instruction Mix の順にソートしてあります。

Algorithm Instruction Mix GPU Duration FPS PractRand Failed
PCG4D 29 27.65 2652 42
PCG3D 38 28.67 2700 42
lowbias32 41 28.67 2638 42
IQInt2 42 26.62 2622 42
Wyhash 87 28.67 2636 42
Philox 294 62.46 2729 42
ibuki 26 27.65 2681 41
MurmurHash3 43 39.94 2683 41
CityHash 49 27.65 2695 41
ESGTSA 38 26.62 2721 40
triple32 53 27.65 2603 39
PCG 38 29.7 2704 38
MD5 227 78.85 2748 > 38
Wang 41 27.65 2586 35
AESCTR 1021 4970 133 > 35
Ranlim32 79 27.65 2595 28
PCG2D 37 27.65 2664 27
xxHash32 42 27.65 2676 27
PCG3D16 30 27.65 2706 25
TEA 87 29.7 2626 21
JenkinsHash 93 27.65 2721 21
Superfast 43 26.62 2636 19
heptaplex-collapse 46 28.67 2557 19
IQInt32 34 27.65 2728 18
IQInt1 30 28.67 2630 17
fihash 9 28.67 2642 16
Interleaved Gradient Noise 10 26.62 2632 16
Trig 11 27.65 2639 16
LCG 14 27.65 2695 16
Fast 16 27.65 2664 16
fast32hash 17 28.67 2653 16
Pseudo 20 27.65 2605 16
PerlinPerm 21 128 2501 16
IQInt3 24 27.65 2580 16
Hash without Sine 31 28.67 2702 16
Xorshift32 33 30.72 2636 16
mod289 39 27.65 2656 16
BBS4093 49 27.65 2667 16
FNV1 50 30.72 2656 16
BBS65521 53 27.65 2735 16
Xorshift128 10 26.62 2601 0
JKISS32 15 26.62 2687 0
HybridTaus 25 27.65 2649 0

余談:GPU によって sin() の返り値が違う問題

シェーダーの sin() などの数学関数は 環境依存 であり、 GPU によって結果が異なる場合がある……と hashwosine の項で書きました。
これは本当なのでしょうか?

実際に試してみましょう。
一番お手軽なのが PC (NVIDIA GeForce RTX 3060 Ti) と Android (ASUS_I005DC; Adreno 660) 間での比較ですね。
Unity でコンピュートシェーダーを書いてビルドして試してみました。

ついでに、ググっていたら NVIDIA GPU における sin() はこういうコードで実装されている場合がある、とありました。

// see: https://developer.download.nvidia.com/cg/sin.html  
static float pseudoSin(float a)  
{  
    var c0 = new Vector4(0.0f, 0.5f, 1.0f, 0.0f);  
    var c1 = new Vector4(0.25f, -9.0f, 0.75f, 0.159154943091f);  
    var c2 = new Vector4(24.9808039603f, -24.9808039603f,  
                        -60.1458091736f, 60.1458091736f);  
    var c3 = new Vector4(85.4537887573f, -85.4537887573f,  
                        -64.9393539429f, 64.9393539429f);  
    var c4 = new Vector4(19.7392082214f, -19.7392082214f,  
                        -1.0f, 1.0f);  
  
    Vector3 r0, r1, r2;  
  
    r1.x = c1.w * a - c1.x;                
    r1.y = frac(r1.x);                    
    r2.x = (r1.y < c1.x) ? 1 : 0;          
    r2.y = (r1.y >= c1.y) ? 1 : 0;  
    r2.z = (r1.y >= c1.z) ? 1 : 0;  
    r2.y = Vector3.Dot(r2, new Vector3(c4.z, c4.w, c4.z));               
    r0 = new Vector3(c0.x - r1.y, c0.y - r1.y, c0.z - r1.y);  
    r0 = new Vector3(r0.x * r0.x, r0.y * r0.y, r0.z * r0.z);  
    r1 = new Vector3(c2.x * r0.x + c2.z, c2.y * r0.y + c2.w, c2.x * r0.z + c2.z);  
    r1 = new Vector3(r1.x * r0.x + c3.x, r1.y * r0.y + c3.y, r1.z * r0.z + c3.x);  
    r1 = new Vector3(r1.x * r0.x + c3.z, r1.y * r0.y + c3.w, r1.z * r0.z + c3.z);  
    r1 = new Vector3(r1.x * r0.x + c4.x, r1.y * r0.y + c4.y, r1.z * r0.z + c4.x);  
    r1 = new Vector3(r1.x * r0.x + c4.z, r1.y * r0.y + c4.w, r1.z * r0.z + c4.z);  
    return Vector3.Dot(r1, -r2);                  
}  

これと値が一致するかも比べてみましょう。
0 ~ PI/2 (90°) まで、 256 等分した値をそれぞれの sin に与えて比較しました。

<GPU ネイティブの sin()> = <NVIDIA の sin()> ; <CPU の Mathf.Sin()> というフォーマットです。

Android

0 = 0 ; 0
0.006135901 = 0.006135881 ; 0.006135885
0.01227157 = 0.01227158 ; 0.01227154
0.01840678 = 0.01840681 ; 0.01840673
0.0245413 = 0.02454126 ; 0.02454123
0.03067489 = 0.03067487 ; 0.0306748
0.03680732 = 0.03680724 ; 0.03680722
0.04293814 = 0.04293823 ; 0.04293826
0.04906758 = 0.04906774 ; 0.04906768
0.05519516 = 0.05519527 ; 0.05519525
0.06132067 = 0.06132072 ; 0.06132074
0.06744387 = 0.06744397 ; 0.06744392
0.07356453 = 0.07356453 ; 0.07356457
0.07968242 = 0.07968247 ; 0.07968244
0.08579731 = 0.08579737 ; 0.08579732
0.09190897 = 0.09190899 ; 0.09190895
0.09801717 = 0.0980171 ; 0.09801714
0.1041217 = 0.1041216 ; 0.1041216
0.1102223 = 0.1102223 ; 0.1102222
0.1163187 = 0.1163187 ; 0.1163186
0.1224108 = 0.1224107 ; 0.1224107
0.128498 = 0.1284981 ; 0.1284981
0.1345806 = 0.1345807 ; 0.1345807
0.1406582 = 0.1406583 ; 0.1406582
0.1467304 = 0.1467305 ; 0.1467305
0.1527971 = 0.1527972 ; 0.1527972
0.1588581 = 0.1588582 ; 0.1588582
0.1649131 = 0.1649132 ; 0.1649131
0.1709619 = 0.1709619 ; 0.1709619
0.1770042 = 0.1770043 ; 0.1770042
0.1830399 = 0.1830398 ; 0.1830399
0.1890687 = 0.1890687 ; 0.1890687
0.1950904 = 0.1950904 ; 0.1950903
0.2011047 = 0.2011046 ; 0.2011046
0.2071115 = 0.2071114 ; 0.2071114
0.2131102 = 0.2131104 ; 0.2131103
0.2191012 = 0.2191013 ; 0.2191012
0.2250838 = 0.2250839 ; 0.2250839
0.2310581 = 0.2310581 ; 0.2310581
0.2370236 = 0.2370237 ; 0.2370236
0.2429802 = 0.2429802 ; 0.2429802
0.2489276 = 0.2489277 ; 0.2489276
0.2548656 = 0.2548657 ; 0.2548657
0.2607941 = 0.2607941 ; 0.2607941
0.2667128 = 0.2667128 ; 0.2667128
0.2726214 = 0.2726214 ; 0.2726214
0.2785197 = 0.2785197 ; 0.2785197
0.2844076 = 0.2844076 ; 0.2844076
0.2902848 = 0.2902847 ; 0.2902847
0.2961508 = 0.2961509 ; 0.2961509
0.3020059 = 0.302006 ; 0.3020059
0.3078496 = 0.3078496 ; 0.3078497
0.3136817 = 0.3136817 ; 0.3136818
0.319502 = 0.3195021 ; 0.319502
0.3253103 = 0.3253103 ; 0.3253103
0.3311063 = 0.3311063 ; 0.3311063
0.3368898 = 0.3368899 ; 0.3368899
0.3426607 = 0.3426607 ; 0.3426607
0.3484187 = 0.3484187 ; 0.3484187
0.3541636 = 0.3541635 ; 0.3541635
0.3598951 = 0.3598951 ; 0.3598951
0.3656131 = 0.365613 ; 0.365613
0.3713173 = 0.3713173 ; 0.3713172
0.3770073 = 0.3770074 ; 0.3770074
0.3826834 = 0.3826834 ; 0.3826835
0.388345 = 0.3883451 ; 0.388345
0.393992 = 0.3939921 ; 0.3939921
0.3996242 = 0.3996242 ; 0.3996242
0.4052413 = 0.4052413 ; 0.4052413
0.4108432 = 0.4108432 ; 0.4108432
0.4164295 = 0.4164296 ; 0.4164296
0.4220003 = 0.4220003 ; 0.4220003
0.4275551 = 0.4275551 ; 0.4275551
0.4330939 = 0.4330938 ; 0.4330938
0.4386163 = 0.4386162 ; 0.4386162
0.4441222 = 0.4441222 ; 0.4441222
0.4496114 = 0.4496114 ; 0.4496113
0.4550835 = 0.4550836 ; 0.4550836
0.4605387 = 0.4605387 ; 0.4605387
0.4659764 = 0.4659765 ; 0.4659765
0.4713967 = 0.4713967 ; 0.4713967
0.4767992 = 0.4767992 ; 0.4767992
0.4821838 = 0.4821838 ; 0.4821838
0.4875502 = 0.4875501 ; 0.4875502
0.4928982 = 0.4928982 ; 0.4928982
0.4982277 = 0.4982277 ; 0.4982277
0.5035384 = 0.5035384 ; 0.5035384
0.5088302 = 0.5088301 ; 0.5088302
0.5141028 = 0.5141028 ; 0.5141028
0.5193561 = 0.519356 ; 0.519356
0.5245896 = 0.5245897 ; 0.5245897
0.5298036 = 0.5298036 ; 0.5298036
0.5349975 = 0.5349976 ; 0.5349976
0.5401714 = 0.5401715 ; 0.5401715
0.545325 = 0.545325 ; 0.545325
0.550458 = 0.550458 ; 0.550458
0.5555702 = 0.5555702 ; 0.5555702
0.5606616 = 0.5606616 ; 0.5606616
0.5657318 = 0.5657318 ; 0.5657318
0.5707808 = 0.5707808 ; 0.5707808
0.5758082 = 0.5758082 ; 0.5758082
0.580814 = 0.580814 ; 0.580814
0.5857979 = 0.5857979 ; 0.5857979
0.5907598 = 0.5907597 ; 0.5907597
0.5956992 = 0.5956993 ; 0.5956993
0.6006164 = 0.6006165 ; 0.6006165
0.605511 = 0.6055111 ; 0.605511
0.6103828 = 0.6103828 ; 0.6103828
0.6152316 = 0.6152316 ; 0.6152316
0.6200572 = 0.6200572 ; 0.6200572
0.6248595 = 0.6248595 ; 0.6248595
0.6296383 = 0.6296383 ; 0.6296383
0.6343933 = 0.6343933 ; 0.6343933
0.6391245 = 0.6391245 ; 0.6391245
0.6438316 = 0.6438316 ; 0.6438316
0.6485144 = 0.6485144 ; 0.6485144
0.6531729 = 0.6531729 ; 0.6531729
0.6578068 = 0.6578067 ; 0.6578067
0.6624157 = 0.6624157 ; 0.6624158
0.6669999 = 0.6669999 ; 0.6669999
0.6715589 = 0.671559 ; 0.671559
0.6760927 = 0.6760927 ; 0.6760927
0.680601 = 0.680601 ; 0.680601
0.6850836 = 0.6850836 ; 0.6850837
0.6895405 = 0.6895406 ; 0.6895406
0.6939715 = 0.6939715 ; 0.6939715
0.6983762 = 0.6983763 ; 0.6983763
0.7027548 = 0.7027547 ; 0.7027547
0.7071067 = 0.7071068 ; 0.7071068
0.7114323 = 0.7114322 ; 0.7114322
0.7157309 = 0.7157308 ; 0.7157308
0.7200025 = 0.7200025 ; 0.7200025
0.7242471 = 0.7242471 ; 0.7242471
0.7284644 = 0.7284644 ; 0.7284644
0.7326542 = 0.7326543 ; 0.7326543
0.7368165 = 0.7368165 ; 0.7368166
0.7409511 = 0.7409511 ; 0.7409512
0.7450578 = 0.7450578 ; 0.7450578
0.7491364 = 0.7491364 ; 0.7491364
0.7531868 = 0.7531868 ; 0.7531868
0.7572088 = 0.7572088 ; 0.7572089
0.7612023 = 0.7612024 ; 0.7612024
0.7651672 = 0.7651673 ; 0.7651673
0.7691032 = 0.7691033 ; 0.7691033
0.7730104 = 0.7730105 ; 0.7730104
0.7768884 = 0.7768885 ; 0.7768885
0.7807372 = 0.7807372 ; 0.7807373
0.7845565 = 0.7845566 ; 0.7845566
0.7883464 = 0.7883464 ; 0.7883464
0.7921066 = 0.7921066 ; 0.7921066
0.7958369 = 0.7958369 ; 0.7958369
0.7995372 = 0.7995373 ; 0.7995373
0.8032075 = 0.8032075 ; 0.8032075
0.8068476 = 0.8068476 ; 0.8068476
0.8104572 = 0.8104572 ; 0.8104572
0.8140363 = 0.8140364 ; 0.8140363
0.8175848 = 0.8175848 ; 0.8175848
0.8211026 = 0.8211025 ; 0.8211026
0.8245894 = 0.8245893 ; 0.8245893
0.8280451 = 0.8280451 ; 0.8280451
0.8314696 = 0.8314696 ; 0.8314697
0.8348629 = 0.8348629 ; 0.8348629
0.8382248 = 0.8382247 ; 0.8382247
0.8415551 = 0.841555 ; 0.8415549
0.8448536 = 0.8448536 ; 0.8448536
0.8481205 = 0.8481203 ; 0.8481203
0.8513553 = 0.8513552 ; 0.8513552
0.8545578 = 0.854558 ; 0.854558
0.8577285 = 0.8577286 ; 0.8577287
0.8608669 = 0.8608669 ; 0.860867
0.8639728 = 0.8639728 ; 0.8639728
0.8670461 = 0.8670462 ; 0.8670462
0.8700869 = 0.870087 ; 0.870087
0.873095 = 0.873095 ; 0.873095
0.8760701 = 0.8760701 ; 0.8760701
0.8790122 = 0.8790122 ; 0.8790123
0.8819213 = 0.8819213 ; 0.8819213
0.8847971 = 0.8847971 ; 0.8847971
0.8876396 = 0.8876396 ; 0.8876396
0.8904487 = 0.8904487 ; 0.8904487
0.8932242 = 0.8932243 ; 0.8932243
0.8959663 = 0.8959662 ; 0.8959663
0.8986745 = 0.8986745 ; 0.8986745
0.9013488 = 0.9013488 ; 0.9013489
0.9039893 = 0.9039893 ; 0.9039893
0.9065958 = 0.9065957 ; 0.9065957
0.909168 = 0.909168 ; 0.909168
0.911706 = 0.911706 ; 0.911706
0.9142098 = 0.9142097 ; 0.9142098
0.9166791 = 0.9166791 ; 0.9166791
0.9191139 = 0.9191139 ; 0.9191139
0.9215141 = 0.921514 ; 0.921514
0.9238796 = 0.9238795 ; 0.9238795
0.9262103 = 0.9262102 ; 0.9262102
0.928506 = 0.9285061 ; 0.9285061
0.9307669 = 0.9307669 ; 0.930767
0.9329928 = 0.9329928 ; 0.9329928
0.9351835 = 0.9351835 ; 0.9351835
0.9373389 = 0.937339 ; 0.937339
0.9394591 = 0.9394592 ; 0.9394592
0.9415441 = 0.9415441 ; 0.9415441
0.9435934 = 0.9435934 ; 0.9435934
0.9456073 = 0.9456073 ; 0.9456074
0.9475855 = 0.9475856 ; 0.9475856
0.9495282 = 0.9495282 ; 0.9495282
0.951435 = 0.951435 ; 0.951435
0.953306 = 0.953306 ; 0.953306
0.9551411 = 0.9551412 ; 0.9551412
0.9569403 = 0.9569404 ; 0.9569404
0.9587035 = 0.9587035 ; 0.9587035
0.9604305 = 0.9604305 ; 0.9604306
0.9621214 = 0.9621214 ; 0.9621214
0.9637761 = 0.9637761 ; 0.9637761
0.9653945 = 0.9653944 ; 0.9653944
0.9669765 = 0.9669765 ; 0.9669765
0.9685221 = 0.9685221 ; 0.9685221
0.9700313 = 0.9700313 ; 0.9700313
0.971504 = 0.9715039 ; 0.9715039
0.97294 = 0.97294 ; 0.97294
0.9743394 = 0.9743394 ; 0.9743394
0.9757022 = 0.9757021 ; 0.9757021
0.9770282 = 0.9770281 ; 0.9770281
0.9783173 = 0.9783174 ; 0.9783174
0.9795697 = 0.9795698 ; 0.9795698
0.9807853 = 0.9807853 ; 0.9807853
0.9819639 = 0.9819639 ; 0.9819639
0.9831055 = 0.9831055 ; 0.9831055
0.9842101 = 0.9842101 ; 0.9842101
0.9852777 = 0.9852777 ; 0.9852777
0.9863081 = 0.9863081 ; 0.9863081
0.9873014 = 0.9873014 ; 0.9873014
0.9882575 = 0.9882576 ; 0.9882576
0.9891765 = 0.9891765 ; 0.9891765
0.9900582 = 0.9900582 ; 0.9900582
0.9909027 = 0.9909027 ; 0.9909027
0.9917098 = 0.9917098 ; 0.9917098
0.9924795 = 0.9924796 ; 0.9924796
0.993212 = 0.9932119 ; 0.993212
0.993907 = 0.993907 ; 0.993907
0.9945646 = 0.9945646 ; 0.9945646
0.9951847 = 0.9951847 ; 0.9951847
0.9957674 = 0.9957674 ; 0.9957674
0.9963126 = 0.9963126 ; 0.9963126
0.9968203 = 0.9968203 ; 0.9968203
0.9972904 = 0.9972904 ; 0.9972904
0.9977231 = 0.997723 ; 0.9977231
0.9981181 = 0.9981181 ; 0.9981181
0.9984756 = 0.9984756 ; 0.9984756
0.9987954 = 0.9987954 ; 0.9987954
0.9990777 = 0.9990777 ; 0.9990777
0.9993224 = 0.9993224 ; 0.9993224
0.9995294 = 0.9995294 ; 0.9995294
0.9996988 = 0.9996988 ; 0.9996988
0.9998306 = 0.9998306 ; 0.9998306
0.9999247 = 0.9999247 ; 0.9999247
0.9999812 = 0.9999812 ; 0.9999812

PC

0 = 0 ; 0
0.006135784 = 0.006135883 ; 0.006135885
0.01227134 = 0.0122716 ; 0.01227154
0.01840647 = 0.01840678 ; 0.01840673
0.02454119 = 0.02454128 ; 0.02454123
0.0306749 = 0.03067486 ; 0.0306748
0.03680708 = 0.03680725 ; 0.03680722
0.0429382 = 0.04293833 ; 0.04293826
0.04906752 = 0.04906771 ; 0.04906768
0.05519509 = 0.05519526 ; 0.05519525
0.06132066 = 0.06132074 ; 0.06132074
0.06744379 = 0.06744399 ; 0.06744392
0.07356446 = 0.07356455 ; 0.07356457
0.07968237 = 0.07968249 ; 0.07968244
0.08579737 = 0.08579735 ; 0.08579732
0.09190872 = 0.09190897 ; 0.09190895
0.09801697 = 0.09801711 ; 0.09801714
0.1041216 = 0.1041216 ; 0.1041216
0.1102219 = 0.1102223 ; 0.1102222
0.1163183 = 0.1163187 ; 0.1163186
0.1224106 = 0.1224107 ; 0.1224107
0.1284982 = 0.1284981 ; 0.1284981
0.1345807 = 0.1345807 ; 0.1345807
0.140658 = 0.1406582 ; 0.1406582
0.1467303 = 0.1467305 ; 0.1467305
0.1527971 = 0.1527972 ; 0.1527972
0.158858 = 0.1588582 ; 0.1588582
0.1649131 = 0.1649132 ; 0.1649131
0.1709618 = 0.1709619 ; 0.1709619
0.177004 = 0.1770042 ; 0.1770042
0.1830396 = 0.1830399 ; 0.1830399
0.1890684 = 0.1890686 ; 0.1890687
0.1950903 = 0.1950904 ; 0.1950903
0.2011045 = 0.2011046 ; 0.2011046
0.2071114 = 0.2071114 ; 0.2071114
0.2131103 = 0.2131104 ; 0.2131103
0.2191012 = 0.2191012 ; 0.2191012
0.2250838 = 0.225084 ; 0.2250839
0.2310579 = 0.2310581 ; 0.2310581
0.2370234 = 0.2370237 ; 0.2370236
0.2429801 = 0.2429802 ; 0.2429802
0.2489275 = 0.2489276 ; 0.2489276
0.2548656 = 0.2548657 ; 0.2548657
0.2607938 = 0.2607942 ; 0.2607941
0.2667127 = 0.2667128 ; 0.2667128
0.2726213 = 0.2726214 ; 0.2726214
0.2785195 = 0.2785197 ; 0.2785197
0.2844074 = 0.2844076 ; 0.2844076
0.2902845 = 0.2902847 ; 0.2902847
0.2961509 = 0.2961509 ; 0.2961509
0.3020057 = 0.3020059 ; 0.3020059
0.3078495 = 0.3078496 ; 0.3078497
0.3136816 = 0.3136818 ; 0.3136818
0.3195019 = 0.3195021 ; 0.319502
0.3253103 = 0.3253103 ; 0.3253103
0.331106 = 0.3311063 ; 0.3311063
0.3368898 = 0.3368899 ; 0.3368899
0.3426606 = 0.3426608 ; 0.3426607
0.3484185 = 0.3484187 ; 0.3484187
0.3541633 = 0.3541635 ; 0.3541635
0.3598949 = 0.359895 ; 0.3598951
0.3656131 = 0.365613 ; 0.365613
0.3713171 = 0.3713172 ; 0.3713172
0.3770073 = 0.3770074 ; 0.3770074
0.3826833 = 0.3826834 ; 0.3826835
0.388345 = 0.3883451 ; 0.388345
0.3939919 = 0.393992 ; 0.3939921
0.399624 = 0.3996242 ; 0.3996242
0.4052413 = 0.4052413 ; 0.4052413
0.4108431 = 0.4108432 ; 0.4108432
0.4164295 = 0.4164296 ; 0.4164296
0.422 = 0.4220003 ; 0.4220003
0.427555 = 0.4275551 ; 0.4275551
0.4330938 = 0.4330938 ; 0.4330938
0.438616 = 0.4386162 ; 0.4386162
0.444122 = 0.4441222 ; 0.4441222
0.4496112 = 0.4496114 ; 0.4496113
0.4550835 = 0.4550836 ; 0.4550836
0.4605385 = 0.4605387 ; 0.4605387
0.4659763 = 0.4659765 ; 0.4659765
0.4713967 = 0.4713967 ; 0.4713967
0.4767992 = 0.4767992 ; 0.4767992
0.4821836 = 0.4821838 ; 0.4821838
0.4875499 = 0.4875502 ; 0.4875502
0.4928981 = 0.4928982 ; 0.4928982
0.4982276 = 0.4982277 ; 0.4982277
0.5035383 = 0.5035384 ; 0.5035384
0.5088301 = 0.5088301 ; 0.5088302
0.5141026 = 0.5141028 ; 0.5141028
0.5193559 = 0.519356 ; 0.519356
0.5245895 = 0.5245897 ; 0.5245897
0.5298036 = 0.5298036 ; 0.5298036
0.5349975 = 0.5349976 ; 0.5349976
0.5401714 = 0.5401714 ; 0.5401715
0.545325 = 0.545325 ; 0.545325
0.5504579 = 0.550458 ; 0.550458
0.5555702 = 0.5555702 ; 0.5555702
0.5606615 = 0.5606616 ; 0.5606616
0.5657318 = 0.5657318 ; 0.5657318
0.5707806 = 0.5707808 ; 0.5707808
0.5758082 = 0.5758082 ; 0.5758082
0.5808139 = 0.5808139 ; 0.580814
0.5857978 = 0.5857978 ; 0.5857979
0.5907595 = 0.5907597 ; 0.5907597
0.5956993 = 0.5956993 ; 0.5956993
0.6006165 = 0.6006165 ; 0.6006165
0.6055108 = 0.6055111 ; 0.605511
0.6103826 = 0.6103828 ; 0.6103828
0.6152316 = 0.6152316 ; 0.6152316
0.6200572 = 0.6200572 ; 0.6200572
0.6248594 = 0.6248595 ; 0.6248595
0.629638 = 0.6296383 ; 0.6296383
0.6343932 = 0.6343933 ; 0.6343933
0.6391243 = 0.6391245 ; 0.6391245
0.6438313 = 0.6438316 ; 0.6438316
0.6485143 = 0.6485144 ; 0.6485144
0.6531728 = 0.6531729 ; 0.6531729
0.6578067 = 0.6578067 ; 0.6578067
0.6624157 = 0.6624158 ; 0.6624158
0.6669998 = 0.6669999 ; 0.6669999
0.6715588 = 0.671559 ; 0.671559
0.6760926 = 0.6760927 ; 0.6760927
0.6806009 = 0.680601 ; 0.680601
0.6850834 = 0.6850837 ; 0.6850837
0.6895405 = 0.6895406 ; 0.6895406
0.6939713 = 0.6939715 ; 0.6939715
0.6983762 = 0.6983762 ; 0.6983763
0.7027546 = 0.7027547 ; 0.7027547
0.7071068 = 0.7071068 ; 0.7071068
0.7114322 = 0.7114322 ; 0.7114322
0.7157307 = 0.7157308 ; 0.7157308
0.7200024 = 0.7200025 ; 0.7200025
0.724247 = 0.7242471 ; 0.7242471
0.7284644 = 0.7284644 ; 0.7284644
0.7326542 = 0.7326543 ; 0.7326543
0.7368164 = 0.7368166 ; 0.7368166
0.7409511 = 0.7409511 ; 0.7409512
0.7450578 = 0.7450578 ; 0.7450578
0.7491363 = 0.7491364 ; 0.7491364
0.7531867 = 0.7531868 ; 0.7531868
0.7572088 = 0.7572088 ; 0.7572089
0.7612023 = 0.7612024 ; 0.7612024
0.7651672 = 0.7651673 ; 0.7651673
0.7691032 = 0.7691033 ; 0.7691033
0.7730104 = 0.7730105 ; 0.7730104
0.7768884 = 0.7768885 ; 0.7768885
0.7807373 = 0.7807372 ; 0.7807373
0.7845566 = 0.7845566 ; 0.7845566
0.7883464 = 0.7883464 ; 0.7883464
0.7921065 = 0.7921066 ; 0.7921066
0.7958369 = 0.7958369 ; 0.7958369
0.7995371 = 0.7995373 ; 0.7995373
0.8032075 = 0.8032075 ; 0.8032075
0.8068476 = 0.8068476 ; 0.8068476
0.8104572 = 0.8104572 ; 0.8104572
0.8140362 = 0.8140363 ; 0.8140363
0.8175848 = 0.8175848 ; 0.8175848
0.8211026 = 0.8211025 ; 0.8211026
0.8245893 = 0.8245893 ; 0.8245893
0.8280449 = 0.8280451 ; 0.8280451
0.8314695 = 0.8314696 ; 0.8314697
0.8348628 = 0.8348629 ; 0.8348629
0.8382246 = 0.8382247 ; 0.8382247
0.8415549 = 0.841555 ; 0.8415549
0.8448536 = 0.8448536 ; 0.8448536
0.8481203 = 0.8481203 ; 0.8481203
0.8513551 = 0.8513552 ; 0.8513552
0.8545579 = 0.854558 ; 0.854558
0.8577285 = 0.8577286 ; 0.8577287
0.860867 = 0.860867 ; 0.860867
0.8639728 = 0.8639728 ; 0.8639728
0.8670462 = 0.8670462 ; 0.8670462
0.8700869 = 0.870087 ; 0.870087
0.873095 = 0.873095 ; 0.873095
0.8760701 = 0.8760701 ; 0.8760701
0.8790122 = 0.8790122 ; 0.8790123
0.8819212 = 0.8819213 ; 0.8819213
0.8847971 = 0.8847971 ; 0.8847971
0.8876396 = 0.8876396 ; 0.8876396
0.8904487 = 0.8904487 ; 0.8904487
0.8932243 = 0.8932243 ; 0.8932243
0.8959663 = 0.8959662 ; 0.8959663
0.8986745 = 0.8986745 ; 0.8986745
0.9013488 = 0.9013488 ; 0.9013489
0.9039893 = 0.9039893 ; 0.9039893
0.9065956 = 0.9065957 ; 0.9065957
0.9091679 = 0.909168 ; 0.909168
0.9117059 = 0.911706 ; 0.911706
0.9142097 = 0.9142098 ; 0.9142098
0.9166791 = 0.9166791 ; 0.9166791
0.9191139 = 0.9191139 ; 0.9191139
0.921514 = 0.921514 ; 0.921514
0.9238795 = 0.9238795 ; 0.9238795
0.9262102 = 0.9262102 ; 0.9262102
0.928506 = 0.9285061 ; 0.9285061
0.9307669 = 0.9307669 ; 0.930767
0.9329928 = 0.9329928 ; 0.9329928
0.9351835 = 0.9351835 ; 0.9351835
0.9373391 = 0.937339 ; 0.937339
0.9394592 = 0.9394592 ; 0.9394592
0.9415441 = 0.9415441 ; 0.9415441
0.9435934 = 0.9435934 ; 0.9435934
0.9456074 = 0.9456073 ; 0.9456074
0.9475856 = 0.9475856 ; 0.9475856
0.9495282 = 0.9495282 ; 0.9495282
0.951435 = 0.951435 ; 0.951435
0.953306 = 0.953306 ; 0.953306
0.9551411 = 0.9551412 ; 0.9551412
0.9569404 = 0.9569404 ; 0.9569404
0.9587035 = 0.9587035 ; 0.9587035
0.9604305 = 0.9604305 ; 0.9604306
0.9621213 = 0.9621214 ; 0.9621214
0.9637761 = 0.9637761 ; 0.9637761
0.9653944 = 0.9653944 ; 0.9653944
0.9669764 = 0.9669765 ; 0.9669765
0.968522 = 0.9685221 ; 0.9685221
0.9700311 = 0.9700313 ; 0.9700313
0.9715039 = 0.9715039 ; 0.9715039
0.97294 = 0.97294 ; 0.97294
0.9743394 = 0.9743394 ; 0.9743394
0.9757022 = 0.9757021 ; 0.9757021
0.9770282 = 0.9770281 ; 0.9770281
0.9783174 = 0.9783174 ; 0.9783174
0.9795697 = 0.9795698 ; 0.9795698
0.9807853 = 0.9807853 ; 0.9807853
0.9819639 = 0.9819639 ; 0.9819639
0.9831055 = 0.9831055 ; 0.9831055
0.9842101 = 0.9842101 ; 0.9842101
0.9852776 = 0.9852777 ; 0.9852777
0.9863081 = 0.9863081 ; 0.9863081
0.9873014 = 0.9873014 ; 0.9873014
0.9882575 = 0.9882576 ; 0.9882576
0.9891765 = 0.9891765 ; 0.9891765
0.9900582 = 0.9900582 ; 0.9900582
0.9909026 = 0.9909027 ; 0.9909027
0.9917097 = 0.9917098 ; 0.9917098
0.9924796 = 0.9924796 ; 0.9924796
0.993212 = 0.9932119 ; 0.993212
0.993907 = 0.993907 ; 0.993907
0.9945646 = 0.9945646 ; 0.9945646
0.9951847 = 0.9951847 ; 0.9951847
0.9957675 = 0.9957674 ; 0.9957674
0.9963127 = 0.9963126 ; 0.9963126
0.9968203 = 0.9968203 ; 0.9968203
0.9972905 = 0.9972904 ; 0.9972904
0.997723 = 0.997723 ; 0.997723
0.9981181 = 0.9981181 ; 0.9981181
0.9984756 = 0.9984756 ; 0.9984756
0.9987954 = 0.9987954 ; 0.9987954
0.9990778 = 0.9990777 ; 0.9990777
0.9993225 = 0.9993224 ; 0.9993224
0.9995294 = 0.9995294 ; 0.9995294
0.9996988 = 0.9996988 ; 0.9996988
0.9998307 = 0.9998306 ; 0.9998306
0.9999248 = 0.9999247 ; 0.9999247
0.9999812 = 0.9999812 ; 0.9999812

ここからわかることは、

  • CPU と GPU で sin() の値が違うこと
  • 実際に GPU によって sin() の値が変わること
  • CPU の Mathf.Sin() は環境によらず同じ値を返していること
  • NVIDIA の sin() 式はどちらとも微妙に違うこと
    • 計算誤差か、 FMA か、そもそも方式が違う (テーブルルックアップとか) か
    • もちろん環境依存なので、たまたま私のデバイスは違うっぽい、ということだけわかる

です。

それから、 GPU の sin() に環境間での再現性を求めてはいけない、ということもわかりますね。
例えばノイズを地形生成とかに使うと同じシードでも微妙に再現できない、みたいな可能性もあります。

おわりに

本稿では、シェーダー乱数の比較検討を行いました。
みなさん frac(sin(...)) のやつはご存知だったかもしれませんが、知らない関数も多かったのではないでしょうか。
本記事が発見のきっかけになったなら幸いです。

また、高速で頑健なシェーダー乱数 IbukiHash を提案しました。
ぜひ使ってあげてください。ライセンスは CC0 です。
どうせ乱数なんてコピペするものなのですから、これを機に frac(sin(...)) のやつから切り替えてみたりしていただければと思います!

IbukiHash

GLSL での実装が欲しい方は、以下の Shadertoy を参照してください。

https://www.shadertoy.com/view/XX3yRn


*1:Jarzynski, Mark, and Marc Olano. "Hash functions for gpu rendering." UMBC Computer Science and Electrical Engineering Department (2020). https://www.jcgt.org/published/0009/03/02/

*2:https://mrl.cs.nyu.edu/~perlin/noise/

*3:Gustavson, Stefan, and Ian McEwan. "Tiling simplex noise and flow noise in two and three dimensions." J Comput Graph Tech 11.1 (2022). https://jcgt.org/published/0011/01/02/paper.pdf

*4:Valdenegro-Toro, Matias, and Hector Pincheira. "Implementing Noise with Hash functions for Graphics Processing Units." arXiv preprint arXiv:1903.12270 (2019). https://arxiv.org/pdf/1903.12270

*5:A Hash Function for Hash Table Lookup, http://www.burtleburtle.net/bob/hash/doobs.html

*6:Blum, Lenore, Manuel Blum, and Mike Shub. "A simple unpredictable pseudo-random number generator." SIAM Journal on computing 15.2 (1986): 364-383.

*7:"google/cityhash: Automatically exported from code.google.com/p/cityhash", https://github.com/google/cityhash

*8:Schechter, Hagit, and Robert Bridson. "Evolving sub-grid turbulence for smoke animation." Proceedings of the 2008 ACM SIGGRAPH/Eurographics symposium on Computer animation. 2008. https://www.cs.ubc.ca/~rbridson/docs/schechter-sca08-turbulence.pdf

*9:Hash without Sine, https://www.shadertoy.com/view/4djSRW

*10:Howes, Lee, and David Thomas. "Efficient random number generation and application using CUDA." GPU gems 3 (2007): 805-830.

*11:Jorge Jimenez – Next Generation Post Processing in Call of Duty: Advanced Warfare, https://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare/

*12:Integer Hash11, https://www.shadertoy.com/view/llGSzw

*13:Integer Hash33, https://www.shadertoy.com/view/XlXcW4

*14:Integer Hash21, https://www.shadertoy.com/view/4tXyWN

*15:Jones, David. "Good practice in (pseudo) random number generation for bioinformatics applications." URL http://www.cs.ucl.ac.uk/staff/d.jones/GoodPracticeRNG.pdf (2010).

*16:https://en.wikipedia.org/wiki/KISS_(algorithm)

*17:Press, William H., et al. Numerical recipes. Cambridge University Press, London, England, 1988.

*18:Rivest, Ronald. "RFC1321: The MD5 message-digest algorithm." (1992).

*19:Tzeng, Stanley, and Li-Yi Wei. "Parallel white noise generation on a GPU via cryptographic hash." Proceedings of the 2008 symposium on Interactive 3D graphics and games. 2008. ソースコードは https://github.com/1iyiwei/parallel-white-noise/blob/master/src/code/OpenGL/md5.frag

*20:O’neill, Melissa E. "PCG: A family of simple fast space-efficient statistically good algorithms for random number generation." ACM Transactions on Mathematical Software (2014).

*21:Jarzynski, Mark, and Marc Olano. "Hash functions for gpu rendering." UMBC Computer Science and Electrical Engineering Department (2020).

*22:Jarzynski, Mark, and Marc Olano. "Hash functions for gpu rendering." UMBC Computer Science and Electrical Engineering Department (2020).

*23:Press, William H. Numerical recipes 3rd edition: The art of scientific computing. Cambridge university press, 2007.

*24:Hash functions., http://www.azillionmonkeys.com/qed/hash.html

*25:Wheeler, David J., and Roger M. Needham. "TEA, a tiny encryption algorithm." Fast Software Encryption: Second International Workshop Leuven, Belgium, December 14–16, 1994 Proceedings 2. Springer Berlin Heidelberg, 1995.

*26:Integer Hash Function, http://web.archive.org/web/20071223173210/http://www.concentric.net/~Ttwang/tech/inthash.htm

*27:Marsaglia, George. "Xorshift rngs." Journal of Statistical software 8 (2003): 1-6.

*28:Marsaglia, George. "Xorshift rngs." Journal of Statistical software 8 (2003): 1-6.

*29:Cyan4973/xxHash: Extremely fast non-cryptographic hash algorithm, https://github.com/Cyan4973/xxHash

*30:wangyi-fudan/wyhash: The FASTEST QUALITY hash function, random number generators (PRNG) and hash map., https://github.com/wangyi-fudan/wyhash

*31:Prospecting for Hash Functions, https://nullprogram.com/blog/2018/07/31/

*32:Prospecting for Hash Functions, https://nullprogram.com/blog/2018/07/31/

*33:Creating reliable and efficient random hash for use in GPU shaders | by Lumi | Oct, 2024 | Medium, https://medium.com/@lumi_/creating-reliable-and-efficient-random-hash-for-use-in-gpu-shaders-fe5b5c9b6b72

*34:A fast and simple 32bit floating point hash function | briansharpe, https://briansharpe.wordpress.com/2011/11/15/a-fast-and-simple-32bit-floating-point-hash-function/

*35:Salmon, John K., et al. "Parallel random numbers: as easy as 1, 2, 3." Proceedings of 2011 international conference for high performance computing, networking, storage and analysis. 2011.

*36:Rijmen, Vincent, and Joan Daemen. "Advanced encryption standard." Proceedings of federal information processing standards publications, national institute of standards and technology 19 (2001): 22.

*37:heptaplex-collapse noise, https://www.shadertoy.com/view/ms3czf

*38:Steele Jr, Guy L., and Sebastiano Vigna. "Computationally easy, spectrally good multipliers for congruential pseudorandom number generators." Software: Practice and Experience 52.2 (2022): 443-458.

NVIDIA Nsight Graphics を使ってみる

はじめに

みなさんはシェーダーのプロファイリングをしたくなったとき、どうやっていますか?
FPS 測定?でもオーバヘッドが大きいし、具体的にどこが重いのかわからない……

みたいな悩みを、 NVIDIA Nsight Graphics で解決できるかもしれません。

ほかにいい手段をご存知の方はぜひご連絡ください……

手順

まずは こちら から NVIDIA Nsight Graphics をインストールします。

インストールの待ち時間に、 Unity プロジェクトの用意をします。
対象となるシェーダーを書いて、マテリアルを作成、それを適用した Plane なり Cube なりを配置します。
カメラに写ってさえいれば OK です。むしろ余計なものがあると解析の邪魔になるので最低限で大丈夫です。

シェーダーですが、以下の #pragma が必要となるので、シェーダーコードに書いておきます。

// #pragma fragment の下あたりに入れる  
  
// デバッグシンボルを含める  
#pragma enable_d3d11_debug_symbols  
  
// shader model 6.0 を使う  
#pragma use_dxc  

これがないと詳細な解析ができません。

なお、 #pragma enable_d3d11_debug_symbols はパフォーマンスに若干の悪影響を与える可能性があります。 ( →参照 )
デバッグが終わったら消しておくのが無難です。
なんで use_dxc で SM6.0 が使えるのかは こちら を参照してください。

ビルドしたアプリを解析することになるので、アプリビルドの準備をします。

まずは Project Settings / Player / Other Settings / Rendering から Auto Graphics API for Windows のチェックを外し、一番上に Direct3D12 が来るようにします。

デフォルトの Direct3D11 だと解析に失敗します。
Vulkan でも動作しますが、デコンパイル結果などが異なり、見慣れない感じになります。
そちらに慣れている方は Vulkan のほうがいいかもしれませんが、私は DirectX をお勧めします。

そうしたら、 Windows 用にアプリをビルドします。
Development Build で大丈夫です。

ビルドしたら、 NVIDIA Nsight Graphics を 管理者権限で 実行します。
管理者権限でないと一部データの取得に失敗します。

起動したら、 Start Activity... を選んでください。

Application Executable: にさっきビルドしたアプリのパスを入れます。
そうしたら右下の Launch Frame Debugger を押します。

ここで Steam を起動していると Interfering Processes Detected と表示され、 Steam のアプリが干渉するとか言われるのですが、気にせず Keep を押して大丈夫です。

うまくいけば、ビルドしたアプリが起動します。

ここで Direct3D11on12 is unsupported みたいな警告が出ますが、とりあえずは無視しても大丈夫そうです。

アプリ上で F11 キーを押すとキャプチャできるので、押します。

すると、 Unity の Profiler みたいな画面が開きますので、解析をします。

まずは Events タブから、描画していそうなイベントを探します。
色がついているので、それを見ながら探してください。
私の場合は DrawIndexedInstanced というイベントがそれでした。検索フィルタもあるのでそれを活用すると見つけやすいかもです。

クリックすると、 API Inspector タブが更新されます。
PS (Pixel Shader) ボタンを押して Profile Shaders ↗ を押します。

すると Shader Profiler が開きます。
ここでは命令数やその内訳、かかった時間などを見ることができます。
カーソルを合わせると詳細な説明がありますので適宜見てください。

ここで、 Correlation 行に警告が出ていた場合はたぶんシェーダーコードの設定を書き忘れています。
#pragma をちゃんと書いたか確認してください。

詳しい説明は 本家の説明書 を参照してください。

ストールの種類と説明 (MIO Throttle とは何ぞや、みたいなの) は こちら 。

処理にかかった時間は下段の Events / GPU Duration のほか、 Events タブの GPU ms から確認できます。
Hot Spots からは重い処理がどこかを突き止めることができます。

動的にシェーダーを編集することもできる (!) らしいですが、一度編集してしまうとソースコードと照らし合わせた解析ができなくなるっぽいので、あまり有効ではなさそうです。

おわりに

シェーダーのプロファイリングなんもわからん状態だったのでとても感動して記事にしました。
とりあえず導入してみた感じですが、プロファイリングが詳しいしいろんな機能があるようなのでいろいろ触ってみたいと思います。

参考文献

User Guide — NsightGraphics 2024.3 documentation
公式のユーザーガイドです。

ノイズを詳しく見てみよう ~ パーリンノイズ・シンプレックスノイズのしくみ

はじめに

グラディエントノイズ (Gradient Noise) は、パーリンノイズやシンプレックスノイズが属する、勾配 (gradient, ∇) を利用したノイズ生成アルゴリズムの総称です。
パーリンノイズは聞いたことのある方が多いと思います。シンプレックスノイズはその改良版みたいなものです。

2 次元パーリンノイズ 2 次元シンプレックスノイズ

本稿では、これらのノイズがどのようにして設計されているのかを紹介したいと思います。

なお、特に記載がないコード片は Unity 上で動作する HLSL コードです。
残念ながら HLSL はシンタックスハイライトが効かないので C# と同じハイライトを使っています。

パーリンノイズ

パーリンノイズ (Perlin Noise) は 1983 年に Ken Perlin 氏によって設計されたノイズです。 *1

これについては分かりやすい文献がたくさんあるかと思いますが、あえてここでも説明しましょう。

勾配ベクトルの生成

まずは単位長さ 1 の四角形 (または立方体、超立方体、……) の格子をつくり、その交点の上にランダムな単位ベクトル = 勾配ベクトルを設定します。

ランダムな、とは言ったものの、実行ごとに変わるものではなく、座標が同じであれば常に同一のベクトルを返すようにするのが一般的です。普通の乱数というよりは、ハッシュ関数に座標を入れた感じ、というのが伝わりやすいでしょうか。

今回は permutation を参照する方式で生成します。
具体的には、まず 0 ~ 255 の連番の順序をシャッフルした定数テーブル permutation を作成します。

static int permutation[512] = {  
151,160,137,91,90,15,  
131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,  
190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,  
88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,  
77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,  
102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,  
135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,  
5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,  
223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,  
129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,  
251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,  
49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,  
138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180,  
  
151,160,137,91,90,15,  
131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,  
190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,  
88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,  
77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,  
102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,  
135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,  
5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,  
223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,  
129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,  
251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,  
49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,  
138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180  
};  

この定数テーブルは由緒正しい https://mrl.cs.nyu.edu/~perlin/noise/ のものです。
ポイントは、 2 回繰り返して長さ 512 の配列にしているところです (前後は同じ順番である必要があります) 。この工夫によって後続する処理が簡単になります。

そうしたら、乱数 (ランダムなベクトル) を取得したい座標が渡されたとき、以下のようにしてベクトルを取得します。

float2 grad2(float2 pos)  
{  
    // 乱数 [0, 255]  
    int p = permutation[((int)pos.x & 255) + permutation[((int)pos.y & 255)]];  
      
    // 円周を 256 等分してできた単位ベクトルのどれかを返す  
    return float2(cos(p / 256.0 * 6.283185), sin(p / 256.0 * 6.283185));  
}  

permutation[x + permutation[y]] のようにすることで、実質ランダムな数値を得ることができます。 x も permutation[y] も高々 255 までなので、 mod (%) をとらなくても配列の長さ 512 をオーバーすることはありません。このために繰り返す必要があったのですね。

ここで (int)pos.x & 255 しているため、この乱数は距離 256 ごとにループします。通常の使用の上では問題ないでしょう。

これを忘れて、別のノイズを得るために pos + float2(256, 256) とすると pos と同じ値が取得されるだけなので注意してください。

なお、より高次元の pos から得たい場合には、 permutation[x + permutation[y + permutation[z]]] のように連結していけば OK です。

擬似乱数生成器の設計者の端くれとしては、こういう乱数生成の方法は品質が低くて即座にテストに落ちそうでぐんにょりするのですが、速くて簡単で移植性もあるので文句は言っていられません。
int とビット演算を正しく扱えるシェーダーであれば、 murmurhash3_32 のファイナライザのようなハッシュ関数を用いる手もありますが、結局そこまでビジュアルに影響はありません。
見た目が悪くなければ問題にはならないので、ここでは乱数の質はあまり気にしなくてもよいでしょう。

同一座標でも異なるノイズが欲しい場合には、この permutation をシャッフルしなおす手があります。
再現が必要な場合は順番を記憶しておくのを忘れないようにしてください。

内積をとる

値を知りたい特定のピクセル (座標) について、それを囲む勾配ベクトルを取得します。 2 次元なら 4 つ、 3 次元なら 8 つ、一般化すれば n 次元のとき 2^{n} 個です。
勾配ベクトルの座標は、求めたい座標の整数部と、それに 1 を足した座標から得られます。
例えば、 2 次元で (0.123, 4.567) なら、原点となる整数部 (0, 4) と、それぞれの次元に 1 を足した (1, 4), (0, 5), (1, 5) の 4 点の勾配ベクトルを取得します。

また、それらに対応するオフセット (ベクトル) を計算します。オフセットは、 (特定のピクセルの座標 - 勾配ベクトルの座標) で求められます。

そうしたら、オフセットと勾配ベクトルの内積をとります。 2 次元なら \vec{a} \cdot \vec{b} = a_x b_x + a_y b_y 、 3 次元なら \vec{a} \cdot \vec{b} = a_x b_x + a_y b_y + a_z b_zで求められます。

各点に一番近い勾配ベクトルとの内積の値を下図に示します。

グラデーションになってきましたね。
この時、格子点上ではオフセットが \vec{0} になるので、内積も 0 になることに注意してください。

補間

内積が求められたら、特定ピクセル (座標) との距離に応じて内積の値を補間します。

ここでの補間は、線形補間ではなく、両端で一階微分が 0 になるような (そして可能であれば二階微分も 0 であるような) 関数を用います。そうすることで連続した、滑らかな結果を得ることができます。
オリジナルの実装では、 smoothstep として知られる関数 \mathrm{smoothstep}(t) = -2 t^{3} + 3 t^{2} を用いて、以下のように実装していました。

float interpolate(float x, float y, float t)  
{  
    return (y - x) * (3 - t * 2) * t * t + x;  
}  

これを求めたいピクセル全部に処理すれば、パーリンノイズは完成です。値域は \lbrack -1, 1 \rbrack とされています。

一般に値域は \lbrack -1, 1 \rbrack である、とされていますが、 The range of Perlin noise - Digital Freepen によると、厳密には \lbrack -\sqrt{n/4}, \sqrt{n/4} \rbrack (n は次元数) らしいです。具体的には、 2 次元パーリンノイズであれば \lbrack -0.707, +0.707 \rbrack 程度となります。

これはまた別のお話ですが、 Unity の Mathf.PerlinNoise() は値域が \lbrack 0, 1 \rbrack に正規化されているはずなのですが、 ごくまれにその範囲外の値を出力することがある そうです。

実際のコードはこんな感じになります。

// 勾配ベクトルの取得  
float2 grad2(float2 pos)  
{  
    int p = permutation[((int)pos.x & 255) + permutation[((int)pos.y & 255)]];  
      
    return float2(cos(p / 256.0 * 6.283185), sin(p / 256.0 * 6.283185));  
}  
  
// 補間 (3次式)  
float interpolate3(float x, float y, float t)  
{  
    return x + (y - x) * (3 - t * 2) * t * t;  
}  
  
// 2 次元パーリンノイズ  
float perlin2(float2 pos)  
{  
    float2 intpos0 = floor(pos);  
    float2 intpos1 = intpos0 + 1;  
    float2 fracpos = pos - intpos0;  
  
    // pos を囲む 4 つの格子点を取得  
    float2 p00 = float2(intpos0.x, intpos0.y);  
    float2 p01 = float2(intpos0.x, intpos1.y);  
    float2 p10 = float2(intpos1.x, intpos0.y);  
    float2 p11 = float2(intpos1.x, intpos1.y);  
      
    // 勾配ベクトルを取得  
    float2 grad00 = grad2(p00);  
    float2 grad01 = grad2(p01);  
    float2 grad10 = grad2(p10);  
    float2 grad11 = grad2(p11);  
  
    // オフセットベクトルと内積をとる  
    float value00 = dot(grad00, pos - p00);  
    float value01 = dot(grad01, pos - p01);  
    float value10 = dot(grad10, pos - p10);  
    float value11 = dot(grad11, pos - p11);  
      
    // 補間  
    float intp0  = interpolate3(value00, value01, fracpos.y);  
    float intp1  = interpolate3(value10, value11, fracpos.y);  
    float intp   = interpolate3(intp0, intp1, fracpos.x);   
  
    return intp;  
}  

改良パーリンノイズ

パーリンノイズの発表後しばらくして、 Ken Perlin 氏によって改良が施されました。

まずは勾配ベクトルの選択です。もともとは単位円 (球) 上のランダムな勾配ベクトルを利用していましたが、それを立方体の辺の中心を指すベクトルだけに限定しました。 3 次元なら 12 種類になります。

上図の中心から伸びる赤い線がそれにあたります。
これによって内積の計算が簡略化され、加減算のみで (乗算なしに) 計算できるようになります。

例えば、 12 種のベクトルのうちのひとつ (1, -1, 0) について考えます。
内積は \vec{a} \cdot \vec{b} = a_x b_x + a_y b_y + a_z b_z で求められますが、 \vec{b} がそのベクトルだとすると \vec{a} \cdot \vec{b} = a_x - a_y となるわけです。
実際のコードでは、そもそも内積 (dot 関数) を使わずに、分岐で処理されています。

なお、 2 次元パーリンノイズにおいては、 3 次元のベクトルの xy 成分のみを使う感じにするそうです。そうなると重複があるので、実質 8 種類となります。

ランダムな勾配ベクトル 12 種の勾配ベクトル

個人的には直線っぽい要素が増えるので果たして「改良」といえるのか微妙なところはあります。遠目に見ればあるいは……?

加えて、勾配ベクトルが単位長ではない (長さが 1 ではない) ことにも注目してください。右のほうが微妙に濃淡が濃くなっているのに気づかれたでしょうか。


もう一つは補間関数の変更です。

オリジナルでは smoothstep でしたが、改良版では smootherstep として知られる関数 \mathrm{smootherstep}(t) = 6 t^{5} - 15 t^{4} + 10 t^{3} が用いられています。これは二階微分も 0 になるため、よりスムーズに補完されます。

グラフにするとこんな感じです。 0・1 付近でより水平に近くなっているのが分かるでしょうか。

微分がよくわからない(私のような)人のための注釈なのですが、
例えば線形補間 \mathrm{lerp}(t) = t の一階微分は 1 なので条件を満たしません。
実際にこれで実装すると明らかに境界の「折れ目」が見えるかと思います。
また、 smoothstep の一階微分は -6t^{2} + 6t なので、両端 (t = 0, 1) で 0 になります。しかし、二階微分 -12t + 6 は 0 にならないのでちょっと不足があります。
smootherstep の一階微分は 30t^{4} - 60t^{3} + 30t^{2} 、二階微分は 120t^{3} - 180t^{2} + 60t となりますが、これは両端できちんと 0 になるので、ノイズでの利用に適していることになります。

smoothstep smootherstep

気持ち滑らかになった……かも……?

なぜ両端で二階微分を 0 にすべきなのかというと、法線を利用する場合 (= バンプマップや変形にパーリンノイズを利用する場合) のクオリティを上げるためです。

例えば、 2 次元のパーリンノイズを使ってバンプマップを生成したとしましょう。
このとき、ライティングに法線が利用されます。
法線は接線に垂直ですので、接線 (= 一階微分) が連続であれば法線も連続に、要するに滑らかになります。一階微分が連続であるためには、二階微分が両端で 0 になる必要があります。
逆に言えば、そうでない (smoothstep の) 場合はライティングに不自然な切れ目が見える可能性が出てくる、というわけです。

パーリンノイズでは正直違いがわからなかったので、より四角形が目立つバリューノイズで比較してみましょう。

smoothstep smootherstep

左の画像はなんとなく影や光が当たる部分で四角形の境界線が見えるような気がしますね。

解析的な導関数

さて、微分の話が出たので解析的な導関数 (analytic derivative) についても考えてみましょう。

解析的な導関数というのは、要するにある点での (偏) 微分を数学的に求めたものです。
これの逆が数値微分で、微小な \Delta を使って (f(x + \Delta) - f(x)) / \Delta のようにして求める手法です。
数値微分は関数についての知識が必要なく、簡単に求められる利点がありますが、計算コストが純増します (1 次元あたり最低 2 点の値を取得する必要があるため) 。そのうえ誤差も含みます。
解析的に微分を解くことができれば、計算コストが低く抑えられ、加えて誤差なく求めることができます。

微分できて何が嬉しいのかというと、例えばバンプマップ (法線マップ) の法線はほぼ微分そのものです (微分で求められる接線を垂直にしたのが法線) 。
また、カールノイズといった応用に使うこともできます (後述)。

結構ややこしい話になりそうな予感がするので、まずは 1 次元の場合について考えてみましょう。

1 次元パーリンノイズを式として書くとこんな感じになります。

  
v = \vec{g_a} \cdot \vec{x_a} + (\vec{g_b} \cdot \vec{x_b} - \vec{g_a} \cdot \vec{x_a}) t(x)

ここで、 v は最終的な値、 a と b は補間対象となる点、 \vec{g_i} は点 i における勾配ベクトル、 \vec{x_i} は点 i におけるオフセットベクトル、 t(x) は補間関数とします。
補間関数は、ここでは五次式の t(x) = 6x^{5} - 15x^{4} + 10x^{3} を使うものとします。

上式は a = \vec{g_a} \cdot \vec{x_a} と書きなおせば、

  
v = a + (b - a) t(x)

なので、 a と b を補間しているのが分かりやすいですね。

まずはこれを微分しましょう。積の微分公式 (f \cdot g)'=f' \cdot g + f \cdot g' より、

  
v' = a' + (b' - a') t(x) + (b - a)t'(x)

そうしたら、各要素についてみていきましょう。まず、 a' と b' は、

  
\begin{align*}  
a' &= (\vec{g_a} \cdot \vec{x_a})' = \vec{g_a} \\  
b' &= (\vec{g_b} \cdot \vec{x_b})' = \vec{g_b}  
\end{align*}

です。次に t'(x) は、

  
\begin{align*}  
t(x) &= 6x^{5} - 15x^{4} + 10x^{3} \\  
t'(x) &= 30x^{4} - 60x^{3} + 30x^{2}  
\end{align*}

となります。したがって、

  
\begin{align*}  
v' &= a' + (b' - a') t(x) + (b - a)t'(x) \\  
&= \vec{g_a} + (\vec{g_b} - \vec{g_a}) t(x) + (b - a) t'(x)  
\end{align*}

となります。ここでポイントとなるのが \vec{g_a} + (\vec{g_b} - \vec{g_a}) t(x) で、これは勾配ベクトル \vec{g_a} と \vec{g_b} を t(x) で補間していることになります。

以降、補間がちょくちょく出てくるのですが、そのまま書いているとわかりにくい&めんどくさいので、

  
a \xrightarrow{t(x)} b = a + (b - a)t(x)

という記法を導入します。これは https://catlikecoding.com/unity/tutorials/pseudorandom-surfaces/perlin-derivatives/ にならいました。
これを使うと、

  
\begin{align*}  
v &= a \xrightarrow{t(x)} b \\  
v' &= a' \xrightarrow{t(x)} b' + (b - a)t'(x)  
\end{align*}

このようにわかりやすくなります。

なお、 a や b 、 t(x) はパーリンノイズ本体の生成でも計算していますので、それを使いまわすことができます。


それでは、 2 次元パーリンノイズの偏微分について考えてみましょう。
a, b, c, d の 4 点を補間するので、

  
\begin{align*}  
v &= (a \xrightarrow{t(y)} b) \xrightarrow{t(x)} (c \xrightarrow{t(y)} d) \\  
&= (a + (b - a)t(y) ) +  ( (c + (d - c)t(y) ) - (a + (b - a)t(y) ) )t(x)  
\end{align*}

となります。すでにめんどくさそう。
ですが、補間する式を微分するとどうなるかは 1 次元パーリンノイズのときにやりましたね。再掲すると、

  
\begin{align*}  
f &= a \xrightarrow{t(x)} b \\  
f' &= a' \xrightarrow{t(x)} b' + (b - a)t'(x)  
\end{align*}

です。整理するためにいったん

  
\begin{align*}  
\alpha &= a \xrightarrow{t(y)} b \\  
\beta &= c \xrightarrow{t(y)} d  
\end{align*}

とおくと、

  
\begin{align*}  
v &= \alpha \xrightarrow{t(x)} \beta \\  
v' &= \alpha' \xrightarrow{t(x)} \beta' + (\beta - \alpha)t'(x)  
\end{align*}

そして、 \alpha と \beta の微分はそれぞれ

  
\begin{align*}  
\alpha' &= a' \xrightarrow{t(y)} b' + (b - a) t'(y) \\  
\beta' &= c' \xrightarrow{t(y)} d' + (d - c) t'(y)  
\end{align*}

となりますので、これを代入すれば、

  
\begin{align*}  
v' &= (a' \xrightarrow{t(y)} b' + (b - a) t'(y) ) \xrightarrow{t(x)} (c' \xrightarrow{t(y)} d' + (d - c) t'(y) ) + ( (c \xrightarrow{t(y)} d) - (a \xrightarrow{t(y)} b) )t'(x) \\  
\end{align*}

となります。計算したくなさがあふれてきますね。
\alpha とかは展開しないまま一時変数として計算するのがよさそうです。

ところで、 t'(x) とか t'(y) は何かというと、実はベクトルです。

  
\begin{align*}  
t'(x) &= \begin{pmatrix} 30x^4 - 60x^3 + 30x^2 \\ 0 \end{pmatrix} \\  
t'(y) &= \begin{pmatrix} 0 \\ 30y^4 - 60y^3 + 30y^2 \end{pmatrix}  
\end{align*}

一次元のときに t(x) を微分して得られた式に、各次元の単位ベクトルを掛けた値になっています。
t'(x) = (\frac{\partial}{\partial x} t(x), \frac{\partial}{\partial y} t(x) )^{T} みたいな解釈をするとよさげです (合っているか微妙) 。

さて、これをコードに落とし込みましょう。

// 5 次の補間式の微分  
float interpolate5derivative(float x, float y, float t)  
{  
    return x + (y - x) * t * t * (t * (t * 30 - 60) + 30);  
}  
  
// (dx, dy, value)  
float3 perlin2(float2 pos)  
{  
    float2 intpos0 = floor(pos);  
    float2 intpos1 = intpos0 + 1;  
    float2 fracpos = pos - intpos0;  
  
    // 各点 (a, b, c, d)  
    float2 p00 = float2(intpos0.x, intpos0.y);  
    float2 p01 = float2(intpos0.x, intpos1.y);  
    float2 p10 = float2(intpos1.x, intpos0.y);  
    float2 p11 = float2(intpos1.x, intpos1.y);  
      
    // 勾配ベクトル  
    float2 grad00 = grad2(p00);  
    float2 grad01 = grad2(p01);  
    float2 grad10 = grad2(p10);  
    float2 grad11 = grad2(p11);  
  
    // 内積  
    float value00 = dot(grad00, pos - p00);  
    float value01 = dot(grad01, pos - p01);  
    float value10 = dot(grad10, pos - p10);  
    float value11 = dot(grad11, pos - p11);  
  
    // 補間した値 (intp が最終結果)      
    float intp0  = interpolate5(value00, value01, fracpos.y);  
    float intp1  = interpolate5(value10, value11, fracpos.y);  
    float intp   = interpolate5(intp0, intp1, fracpos.x);   
  
    // それぞれ t(x), t(y), (t'(x), t'(y))  
    float tx = interpolate5(0, 1, fracpos.x);  
    float ty = interpolate5(0, 1, fracpos.y);  
    float2 tp = float2(  
        interpolate5derivative(0, 1, fracpos.x),   
        interpolate5derivative(0, 1, fracpos.y));  
  
    // α', β'  
    float2 alphap = grad00 + (grad01 - grad00) * ty + (value01 - value00) * float2(0, tp.y);  
    float2 betap  = grad10 + (grad11 - grad10) * ty + (value11 - value10) * float2(0, tp.y);  
  
    // (dx, dy)  
    float2 p = alphap + (betap - alphap) * tx + (intp1 - intp0) * float2(tp.x, 0);  
  
  
    return float3(p, intp);  
}  
  

変数を全部展開すると地獄みたいになるので、一時変数をうまく使って計算するのがよさそうです。


さて、 2 次元が終わったということは次は 3 次元です。
a, b, c, d, e, f, g, h の 8 点について、

  
\begin{align*}  
v &= ( (a \xrightarrow{t(z)} b) \xrightarrow{t(y)} (c \xrightarrow{t(z)} d) ) \xrightarrow{t(x)} ( (e \xrightarrow{t(z)} f) \xrightarrow{t(y)} (g \xrightarrow{t(z)} h) ) \\  
  &= (\alpha \xrightarrow{t(y)} \beta) \xrightarrow{t(x)} (\gamma \xrightarrow{t(y)} \delta) \\  
  &= \varepsilon \xrightarrow{t(x)} \zeta  
\end{align*}

という感じにまとめると、

  
\begin{align*}  
v' &= (\varepsilon' \xrightarrow{t(x)} \zeta') + (\zeta - \varepsilon)t'(x) \\  
\varepsilon' &= (\alpha' \xrightarrow{t(y)} \beta') + (\beta - \alpha)t'(y) \\  
\zeta' &= (\gamma' \xrightarrow{t(y)} \delta') + (\delta - \gamma)t'(y) \\  
\alpha' &= (a' \xrightarrow{t(z)} b') + (b - a)t'(z) \\  
\beta'  &= (c' \xrightarrow{t(z)} d') + (d - c)t'(z) \\  
\gamma' &= (e' \xrightarrow{t(z)} f') + (f - e)t'(z) \\  
\delta' &= (g' \xrightarrow{t(z)} h') + (h - g)t'(z) \\  
\end{align*}

という風になります。展開はしたくないので各自代入してください。なお、 t'(x), t'(y), t'(z) は同様に、

  
\begin{align*}  
t'(x) &= \begin{pmatrix} 30x^4 - 60x^3 + 30x^2 \\ 0 \\ 0 \end{pmatrix} \\  
t'(y) &= \begin{pmatrix} 0 \\ 30y^4 - 60y^3 + 30y^2 \\ 0 \end{pmatrix} \\  
t'(z) &= \begin{pmatrix} 0 \\ 0 \\ 30z^4 - 60z^3 + 30z^2 \end{pmatrix}  
\end{align*}

となります。

// (dx, dy, dz, value)  
float4 perlin3(float3 pos)  
{  
    float3 intpos0 = floor(pos);  
    float3 intpos1 = intpos0 + 1;  
    float3 fracpos = pos - intpos0;  
  
    // 各点 (a, b, c, d, e, f, g, h)  
    float3 p000 = float3(intpos0.x, intpos0.y, intpos0.z);  
    float3 p001 = float3(intpos0.x, intpos0.y, intpos1.z);  
    float3 p010 = float3(intpos0.x, intpos1.y, intpos0.z);  
    float3 p011 = float3(intpos0.x, intpos1.y, intpos1.z);  
    float3 p100 = float3(intpos1.x, intpos0.y, intpos0.z);  
    float3 p101 = float3(intpos1.x, intpos0.y, intpos1.z);  
    float3 p110 = float3(intpos1.x, intpos1.y, intpos0.z);  
    float3 p111 = float3(intpos1.x, intpos1.y, intpos1.z);  
      
    // 勾配ベクトル  
    float3 grad000 = grad3(p000);  
    float3 grad001 = grad3(p001);  
    float3 grad010 = grad3(p010);  
    float3 grad011 = grad3(p011);  
    float3 grad100 = grad3(p100);  
    float3 grad101 = grad3(p101);  
    float3 grad110 = grad3(p110);  
    float3 grad111 = grad3(p111);  
  
    // 内積  
    float value000 = dot(grad000, pos - p000);  
    float value001 = dot(grad001, pos - p001);  
    float value010 = dot(grad010, pos - p010);  
    float value011 = dot(grad011, pos - p011);  
    float value100 = dot(grad100, pos - p100);  
    float value101 = dot(grad101, pos - p101);  
    float value110 = dot(grad110, pos - p110);  
    float value111 = dot(grad111, pos - p111);  
  
    // 補間  
    float intp00 = interpolate5(value000, value001, fracpos.z);  
    float intp01 = interpolate5(value010, value011, fracpos.z);  
    float intp10 = interpolate5(value100, value101, fracpos.z);  
    float intp11 = interpolate5(value110, value111, fracpos.z);  
    float intp0  = interpolate5(intp00, intp01, fracpos.y);  
    float intp1  = interpolate5(intp10, intp11, fracpos.y);  
    float intp   = interpolate5(intp0, intp1, fracpos.x);   
  
  
    // t, t'  
    float3 t = float3(  
        interpolate5(0, 1, fracpos.x),   
        interpolate5(0, 1, fracpos.y),   
        interpolate5(0, 1, fracpos.z));  
    float3 tp = float3(  
        interpolate5derivative(0, 1, fracpos.x),   
        interpolate5derivative(0, 1, fracpos.y),   
        interpolate5derivative(0, 1, fracpos.z));  
  
    float3 d00p = grad000 + (grad001 - grad000) * t.z + (value001 - value000) * float3(0, 0, tp.z);  
    float3 d01p = grad010 + (grad011 - grad010) * t.z + (value011 - value010) * float3(0, 0, tp.z);  
    float3 d10p = grad100 + (grad101 - grad100) * t.z + (value101 - value100) * float3(0, 0, tp.z);  
    float3 d11p = grad110 + (grad111 - grad110) * t.z + (value111 - value110) * float3(0, 0, tp.z);  
    float3 d0p  = d00p + (d01p - d00p) * t.y + (intp01 - intp00) * float3(0, tp.y, 0);  
    float3 d1p  = d10p + (d11p - d10p) * t.y + (intp11 - intp10) * float3(0, tp.y, 0);  
    float3 dp   = d0p + (d1p - d0p) * t.x + (intp1 - intp0) * float3(tp.x, 0, 0);  
      
  
    return float4(dp, intp);  
}  

ここまでついてこれた方なら、 n 次元に対する拡張も自力でできるかと思います。

パフォーマンス

1024x1024 のテクスチャに 2^{20} 回書き込みを行うまでにかかった時間を測定しました。
測定コードはこんな感じです。

using System.Diagnostics;  
using UnityEngine;  
  
public class Bench : MonoBehaviour  
{  
    [SerializeField] private Material m_Material;  
  
  
    void Start()  
    {  
        var rt = RenderTexture.GetTemporary(1024, 1024, 0, RenderTextureFormat.ARGB32);  
        var sw = Stopwatch.StartNew();  
  
        for (int i = 0; i < 1024 * 1024; i++)  
        {  
            Graphics.Blit(null, rt, m_Material);  
        }  
  
        sw.Stop();  
        RenderTexture.ReleaseTemporary(rt);  
  
        UnityEngine.Debug.Log($"{sw.Elapsed.TotalMilliseconds} ms");  
    }  
}  

white はオーバーヘッド計測用の白く塗るだけのシェーダーです。

algorithm time(ms)
white 7509.9471 ms
Perlin2 30717.3353 ms
Perlin3 91323.1859 ms
Perlin4 77242.1085 ms

なんでか分かりませんが、 3 次元ノイズが一番重い結果となりました。なんで?

問題

パーリンノイズにはいくつかの問題点があります。

高次元における計算量の増大

ある点を囲む格子点の数は、前述したように n 次元のとき 2^{n} 個となります。つまり計算量としては O(2^{n}) となるわけで、高次元になればなるほど指数関数的に計算量が増加します。

実用上は多くてせいぜい 4 次元 (→ 16 点) ぐらいなのでそこまで問題になることはないですが、まぁ計算量は少ないに越したことはないです。

格子点上では常に 0

前述したように、格子点のうえでは内積が常に 0 になるので、ノイズの値も常に 0 になります。
じっくり見ないとわからないかもですが、例えば地形生成に利用した場合などでは特有のパターンを生じる可能性があります。

最も、これはパーリンノイズ固有の問題というよりは、グラディエントノイズという手法自体についてまわる問題な気もします。

異方性がある

これが一番大きくてわかりやすい問題かもしれません。

異方性というのは、方向によって性質が異なることです。
パーリンノイズは四角形の格子をベースとして作られているため、上下左右方向と斜め方向、あとそれ以外で性質が異なります。具体的には下図を見ていただければわかるかと思うのですが、上下左右や斜めにまっすぐ走るラインのようなものがおぼろげに見えるかと思います。

加えて、補間の関係上内積の結果も四角形に近づくようになります。

これは単一の勾配ベクトルが周囲に与える影響です。 0 のエリアは分かりやすくするため青に塗っています。白と黒の影響範囲が円形というよりは四角形に近いのがおわかりいただけるでしょうか。

以上のことから、パーリンノイズは異方性があるといえるでしょう。

シンプレックスノイズ

シンプレックスノイズ (Simplex Noise) は、2001 年に Ken Perlin 氏によって提案された、パーリンノイズの改良版にあたるアルゴリズムです。 *2

まずはオリジナルの論文にあるコードを見てみましょう。 3 次元シンプレックスノイズの Java における実装だそうです。
(雰囲気を感じていただければ大丈夫です)

public final class Noise3 {  
    static int i,j,k, A[] = {0,0,0};  
    static double u,v,w;  
    static double noise(double x, double y, double z) {  
        double s = (x+y+z)/3;  
        i=(int)Math.floor(x+s); j=(int)Math.floor(y+s); k=(int)Math.floor(z+s);  
        s = (i+j+k)/6.; u = x-i+s; v = y-j+s; w = z-k+s;  
        A[0]=A[1]=A[2]=0;  
        int hi = u>=w ? u>=v ? 0 : 1 : v>=w ? 1 : 2;  
        int lo = u< w ? u< v ? 0 : 1 : v< w ? 1 : 2;  
        return K(hi) + K(3-hi-lo) + K(lo) + K(0);  
    }  
    static double K(int a) {  
        double s = (A[0]+A[1]+A[2])/6.;  
        double x = u-A[0]+s, y = v-A[1]+s, z = w-A[2]+s, t = .6-x*x-y*y-z*z;  
        int h = shuffle(i+A[0],j+A[1],k+A[2]);  
        A[a]++;  
        if (t < 0)  
        return 0;  
        int b5 = h>>5 & 1, b4 = h>>4 & 1, b3 = h>>3 & 1, b2= h>>2 & 1, b = h & 3;  
        double p = b==1?x:b==2?y:z, q = b==1?y:b==2?z:x, r = b==1?z:b==2?x:y;  
        p = (b5==b3 ? -p : p); q = (b5==b4 ? -q : q); r = (b5!=(b4^b3) ? -r : r);  
        t *= t;  
        return 8 * t * t * (p + (b==0 ? q+r : b2==0 ? q : r));  
    }  
    static int shuffle(int i, int j, int k) {  
        return b(i,j,k,0) + b(j,k,i,1) + b(k,i,j,2) + b(i,j,k,3) +  
            b(j,k,i,4) + b(k,i,j,5) + b(i,j,k,6) + b(j,k,i,7) ;  
    }  
    static int b(int i, int j, int k, int B) { return T[b(i,B)<<2 | b(j,B)<<1 | b(k,B)]; }  
    static int b(int N, int B) { return N>>B & 1; }  
    static int T[] = {0x15,0x38,0x32,0x2c,0x0d,0x13,0x07,0x2a};  
}  

これを読んで完全に理解できた方はもう読まなくてもいいですが、まず無理だと思います。暗号か何か?

シンプレックスノイズを詳しく解説している (実質原典でさえある) "Simplex noise demystified" いわく、

  • より少ない計算量と乗算回数。
  • 高次元での低い計算量 - O(n) オーダー (パーリンノイズは O(2^{n})) 。
  • 目に見える異方性 (方向に依存する性質の異なり) が存在しない。
  • well-defined であり、勾配がどこでも連続していて、かつ計算しやすい。
  • ハードウェアでも実装しやすい。

という性能を実現しています。

どうやってこれらの性質を実現しているのかを詳しく見ていきましょう。

シンプレックスとは

「シンプレックス)」 (simplex) は、日本語で言えば「単体」のことで、数学的には n 次元において n + 1 個の頂点を持つ凸な図形のことを指します。
具体的には、 1 次元では線分、 2 次元では三角形、 3 次元では四面体 (三角錐) ですね。
シンプレックスは、空間を充填できる図形の中では最もシンプルである (頂点の数が少ない) ことが知られています。

シンプレックスノイズでは、格子構造にこのシンプレックスを用いています。
パーリンノイズでは四角形 (立方体) を格子構造に用いていましたが、この場合頂点数が n 次元のとき 2^{n} 個となります。対して、シンプレックスでは n + 1 個に抑えられます。
具体的には、 3 次元のときはパーリンノイズは 8 頂点 → シンプレックスノイズは 4 頂点と半分に、 4 次元なら 16 頂点 → 5 頂点と、大幅に削減することができます。
グラディエントノイズ生成では、格子の頂点上に勾配ベクトルを生成→オフセットとの内積をとる→補間、とするため、頂点が少なければ少ないほど処理も少なく (= 速く) なります。

シンプレックスの空間充填

四角形 (立方体) の空間充填は自明ですが、シンプレックスではどうやって空間充填しているのか気になりますね。
2 次元では正三角形です。これは分かりやすいですね。

3 次元では、少し引き延ばされたような四面体です。

なお、正三角形とは異なり、正四面体 (正三角形で構成された三角錐) だけでは空間充填できません。

これは 6 つ組み合わせると六面体 (対角線方向に引き延ばされた立方体) になるので、あとは立方体と同様に空間充填できます。
下の画像の白い部分が上述の六面体です。周囲の赤い立方体は比較用です (1辺の長さは 2) 。

4 次元ではもはや図示することが困難ですが五胞体になります。これを 24 個組み合わせると八胞体 (歪んだ四次元超立方体) になるので、それと同様に空間充填ができます。

座標変換

さて、シンプレックスノイズの実装にあたっては、「正方形などを分割して構成される格子」と「正三角形などを含む実際の格子」の二種類を相互に変換できるようにする必要があります。

というのも、実際にノイズを生成する際、以下の手順で座標変換を行うからです。

手順
1. 実際の格子上で、欲しい点の位置を指定
2. 正方形の格子に変換
3. 所属する三角形の頂点を取得
4. 実際の格子に変換、各頂点の影響を計算

どうして座標変換をするのかというと、そのほうが計算が簡単だからです。
実際の格子上で「ある点を囲んでいる三角形の頂点」を求めるのは難しく、手間がかかります。
しかし、正方形の格子上でなら格子の長さが 1 なので、例えばある点を (x, y) としたときに (\lfloor x \rfloor, \lfloor y \rfloor) ・ (\lfloor x \rfloor, \lfloor y \rfloor + 1) ・ (\lfloor x \rfloor + 1, \lfloor y \rfloor + 1) というように、非常に簡単に求めることができます。

あとはその座標変換が簡単に、かつ相互にできればいいわけですね。

⇔
実際の格子 正方形の格子

まずは 2 次元の、実際の格子→正方形の格子の変換について考えてみましょう。

アフィン変換を使って考えると、

  1. 時計回りに 45° 回転させ、対角線を x 軸と平行にする
  2. x 軸方向に \sqrt{3} 倍だけ拡大
  3. 反時計回りに 45° 回転させ、元に戻す

とすれば実現できそうです。

2 がなぜ \sqrt{3} 倍なのかというと、 一辺が \sqrt{2/3} の正三角形 (高さ \sqrt{1/2} ) を、底辺 \sqrt{2} ・斜辺 1 ・高さ \sqrt{1/2} の二等辺三角形 (2 つあわせると一辺の長さが 1 の正方形) に変換する際の x 軸方向の倍率が \sqrt{2} / \sqrt{2/3} = \sqrt{3} 倍だからです。
正三角形の一辺の長さが \sqrt{2/3} なのは、この変換をした時に高さ (y 軸方向の倍率) を変えずに変換するためです。

これを行列にすると以下のようになります。
上の作業順と掛け算の順が逆なことに注意してください。また、反時計回りが正方向、時計回りが負方向とします。

  
\begin{pmatrix}  
\cos{45°} && -\sin{45°} && 0\\  
\sin{45°} && \cos{45°} && 0 \\  
0 && 0 && 1  
\end{pmatrix}  
\begin{pmatrix}  
\sqrt{3} && 0 && 0\\  
0 && 1 && 0\\  
0 && 0 && 1  
\end{pmatrix}  
\begin{pmatrix}  
\cos{-45°} && -\sin{-45°} && 0 \\  
\sin{-45°} && \cos{-45°} && 0 \\  
0 && 0 && 1  
\end{pmatrix}  
\\  
= \frac{1}{2}  
\begin{pmatrix}  
\sqrt{3} + 1 && \sqrt{3} - 1 && 0 \\  
\sqrt{3} - 1 && \sqrt{3} + 1 && 0 \\  
0 && 0 && 2  
\end{pmatrix}

となります。
これを (x, y) に適用すると、

  
\begin{pmatrix}  
x' \\ y' \\ 1  
\end{pmatrix}  
=  
\frac{1}{2}  
\begin{pmatrix}  
\sqrt{3} + 1 && \sqrt{3} - 1 && 0 \\  
\sqrt{3} - 1 && \sqrt{3} + 1 && 0 \\  
0 && 0 && 2  
\end{pmatrix}  
\begin{pmatrix}  
x \\ y \\ 1  
\end{pmatrix}  
\\  
= \begin{pmatrix}  
\frac{\sqrt{3} + 1}{2} x + \frac{\sqrt{3} - 1}{2} y \\  
\frac{\sqrt{3} - 1}{2} x + \frac{\sqrt{3} + 1}{2} y \\  
1  
\end{pmatrix}

となります。ところで、 \frac{\sqrt{3}+1}{2} = \frac{\sqrt{3}-1}{2} + 1 ですので、

  
\begin{pmatrix}  
x' \\ y' \\ 1  
\end{pmatrix}  
= \begin{pmatrix}  
\frac{\sqrt{3} - 1}{2} x + x + \frac{\sqrt{3} - 1}{2} y \\  
\frac{\sqrt{3} - 1}{2} x + \frac{\sqrt{3} - 1}{2} y + y\\  
1  
\end{pmatrix}  
= \begin{pmatrix}  
x \\ y \\ 1  
\end{pmatrix} +  
\begin{pmatrix}  
\frac{\sqrt{3} - 1}{2} (x + y) \\  
\frac{\sqrt{3} - 1}{2} (x + y) \\  
0  
\end{pmatrix}

となります。
したがって、元の (x, y) の両方に対して \frac{\sqrt{3} - 1}{2} (x + y) を足せばよい、ということになります。

さて、逆変換、つまり正方形の格子→実際の格子の変換について考えてみましょう。同様に、

  1. 時計回りに 45° 回転させ、対角線を x 軸と平行にする
  2. x 軸方向に 1/\sqrt{3} 倍だけ拡大 (縮小?)
  3. 反時計回りに 45° 回転させ、元に戻す

とすれば実現できそうです。

  
\begin{pmatrix}  
\cos{45°} && -\sin{45°} && 0\\  
\sin{45°} && \cos{45°} && 0 \\  
0 && 0 && 1  
\end{pmatrix}  
\begin{pmatrix}  
1 / \sqrt{3} && 0 && 0\\  
0 && 1 && 0\\  
0 && 0 && 1  
\end{pmatrix}  
\begin{pmatrix}  
\cos{-45°} && -\sin{-45°} && 0 \\  
\sin{-45°} && \cos{-45°} && 0 \\  
0 && 0 && 1  
\end{pmatrix}  
\\  
= \frac{1}{6}  
\begin{pmatrix}  
\sqrt{3} + 3 && \sqrt{3} - 3 && 0 \\  
\sqrt{3} - 3 && \sqrt{3} + 3 && 0 \\  
0 && 0 && 6  
\end{pmatrix}

したがって、

  
\begin{pmatrix}  
x' \\ y' \\ 1  
\end{pmatrix}  
=  
\frac{1}{6}  
\begin{pmatrix}  
\sqrt{3} + 3 && \sqrt{3} - 3 && 0 \\  
\sqrt{3} - 3 && \sqrt{3} + 3 && 0 \\  
0 && 0 && 6  
\end{pmatrix}  
\begin{pmatrix}  
x \\ y \\ 1  
\end{pmatrix}  
\\  
= \begin{pmatrix}  
\frac{\sqrt{3} + 3}{6} x + \frac{\sqrt{3} - 3}{6} y \\  
\frac{\sqrt{3} - 3}{6} x + \frac{\sqrt{3} + 3}{6} y \\  
1  
\end{pmatrix}

同様に、 \frac{\sqrt{3}+3}{6} = \frac{\sqrt{3}-3}{6} + 1 ですので、

  
\begin{pmatrix}  
x' \\ y' \\ 1  
\end{pmatrix}  
= \begin{pmatrix}  
\frac{\sqrt{3} - 3}{6} x + x + \frac{\sqrt{3} - 3}{6} y \\  
\frac{\sqrt{3} - 3}{6} x + \frac{\sqrt{3} - 3}{6} y + y\\  
1  
\end{pmatrix}  
= \begin{pmatrix}  
x \\ y \\ 1  
\end{pmatrix} +  
\begin{pmatrix}  
\frac{\sqrt{3} - 3}{6} (x + y) \\  
\frac{\sqrt{3} - 3}{6} (x + y) \\  
0  
\end{pmatrix}

以上から、元の (x, y) の両方に対して \frac{\sqrt{3} - 3}{6} (x + y) を足せばよい、ということになります。

以上で出てきた定数 \frac{\sqrt{3} - 1}{2} と \frac{\sqrt{3} - 3}{6} は、 "Simplex noise demystified" のソースコードでの F2 と -G2 に相当します。
(G2 の符号が異なることに注意してください。)

F2 を "Skew Factor" 、 -G2 を "Unskew Factor" と呼びます。
実際の空間から歪めて (skew) 正方形の格子にうつすほう、歪みを取り除いて (unskew) 実際の空間に戻すほう、という感じです。


Skew Factor と Unskew Factor は次元によって異なりますので、 3 次元・ 4 次元での定数は別途求める必要があります。

もとの論文を読むと、 n 次元において「実際の格子」での全次元が 1 のベクトル (1, 1, ..., 1) が「正方形の格子」においては (\sqrt{n+1}, \sqrt{n+1}, ..., \sqrt{n+1}) にマップされるように定数を決める、と定義されています。
これをもとに計算してみましょう。

まずは 3 次元です。
定義によれば、「通常の格子」での (1, 1, 1) が「正方形の格子」では (2, 2, 2) になります。したがって、対角線 x = y = z 上の長さを 2 倍にすればよさそうです。

ただ、行列計算がつらくなってきたので、このあたりで式で解いてみることにしましょう。
そもそも、 \vec{1} が (\sqrt{n+1}) \vec{1} にマップされる定義を利用すれば、行列より簡単に Skew/Unskew Factor を求めることができます。
例えば、 3 次元なら x 軸上の変換式より、

  
x + (x + y + z) f = \sqrt{3 + 1}

ここで、 f が Skew Factor です。
初期座標は (x, y, z) = (1, 1, 1) なので、

  
\begin{align*}  
1 + (1 + 1 + 1) f &= \sqrt{3 + 1} \\  
1 + 3 f &= 2 \\  
f &= \frac{1}{3}  
\end{align*}

よって Skew Factor は \frac{1}{3} となります。同様に、 g を Unskew Factor とすると、

  
\begin{align*}  
2 + (2 + 2 + 2) g &= 1 \\  
2 + 6g &= 1 \\  
g &= -\frac{1}{6}  
\end{align*}

したがって Unskew Factor は -\frac{1}{6} となります。

より一般化して考えると、 n 次元のときに、

  
\begin{align*}  
1 + n f_n &= \sqrt{n + 1} \\  
f_n &= \frac{\sqrt{n + 1} - 1}{n}  
\end{align*}

  
\begin{align*}  
\sqrt{n+1} + n\sqrt{n+1} \cdot g_n &= 1 \\  
g_n &= \frac{1 - \sqrt{n+1}}{n\sqrt{n+1}} \\  
&= \frac{\sqrt{n+1} - (n + 1)}{n(n+1)}  
\end{align*}

となります。具体的には、 4 次元では f_4 = \frac{\sqrt{5}-1}{4}, g_4 = \frac{\sqrt{5}-5}{20} になります。

補間から減衰関数と加算へ

さて、格子構造のほかにも改良された点が補間です。

パーリンノイズでは、各点の内積を取った後、補間することでノイズの値を求めていました。
補間は補間で良いのですが、解析的な導関数を求めるのが難しい、めんどくさい、計算が重い、という問題がありました。

シンプレックスノイズでは、距離に応じた減衰関数を用いて、それを各点に適用した後足し合わせることでノイズの値を求めます。
単に加算とすることで、偏微分する時も各点の偏微分を加算するだけでよくなります。

この減衰関数は、基本的に隣の格子を超える前に 0 になるように設計されています。つまり、ある点を囲む格子点以外の影響は 0 となるため、計算しなくてもよくなります。

2 次元格子のこの図で言えば、中央の茶色の点は周囲 3 個の赤い点からの影響を受けています。
透明で大きい赤丸が各点の影響範囲です。図からわかる通り、影響範囲は 1 つの格子ぶんまでしか届かないようになっています。

ここで、 2 次元シンプレックスノイズの減衰関数のコードを見てみましょう:

float t0 = 0.5 - (offset0.x * offset0.x + offset0.y * offset0.y);  
float n0 = t0 < 0 ? 0 : t0 * t0 * t0 * t0 * dot(gradient0, offset0);  

数学っぽく書くと t_0 = ( \max(0.5 - (x^{2} + y^{2}), 0) )^{4} です。
(x^{2} + y^{2}) は中心点からの距離 (以降 d とします) の 2 乗ですね。
ではこの 0.5 は何でしょうか。まずは t_0=(0.5-d^{2})^{4} のグラフを見てみましょう。

縦軸が強さ ( t_0 ) 、横軸が距離 ( d = \sqrt{x^{2} + y^{2}} ) です。距離 0.6 付近で強さはほぼ 0 になっています。
0.8 付近からまた強さが上がってきていますが、これは無視して大丈夫です。減衰関数が適用される最大の長さは \sqrt{2/3} \approx 0.8165 なので、実用上ほぼ影響は及ぼしません。たぶん。

「減衰関数が適用される最大の長さ」をどうやって求めたかを説明しましょう。
実際の格子において、正三角形の一辺の長さは \sqrt{2/3} \approx 0.8165 でしたね。これが三角形のなかで最も遠い点どうしの距離、つまり減衰関数が適用される最大の長さとなります。
また、三角形の高さは \sqrt{2/3} \cos{30°} = \sqrt{1/2} \approx 0.7071 となるため、この距離以上の場合は減衰関数の強さは 0 になるべきです。そうでない場合、三角形の範囲外に色がはみ出してしまい、不自然な境界線が表れてしまいます。
0.5 は、 d = \sqrt{0.5} = \sqrt{1/2} としたときに t_0 = (0.5 - (\sqrt{1/2})^{2})^{4} = (0.5 - 0.5)^{4} = 0 になるように計算されて設定されたものと思われます。たぶん。

上で「実用上ほぼ影響は及ぼしません」と意味深に書きましたが、色深度 8 bpp の範囲では影響は出ないことを確認しています。 HDR だと、あるいは……?

ところで、 3 次元以上のシンプレックスノイズでは減衰関数が t_0 = \max( (0.6 - d^{2})^{4}, 0) に変更されています。

形は概ね似ていますが、縦方向の高さが異なります。

ところでこの関数、 d = \sqrt{1/2} のときの値が t_0 = 0.0001 となっており、微妙に漏れています。これが境界線上での不連続性を生んでおり、実際微妙にラインが見えることがあります。

これは 3 次元シンプレックスノイズの xyz 方向の偏微分を重ね合わせたものなのですが (綺麗ですね!) 、ズームしてよく見ていただくとそこかしこに不自然なラインが走っているのが見えるかと思います。
(見つけられない方は、一番上の中央、黒い領域と赤い領域の境界付近を見てみてください。)

これを受けて、 Stefan Gustavson 氏は改良された減衰関数 t_0 = \max( (0.5 - d^{2})^{3}, 0) を提案しています。 *3

この関数は d = \sqrt{1/2} で厳密に 0 になるほか、それを超えた範囲では負値に (max をとっているので 0 に) なるので、はみ出す心配がありません、また、次数も削減されています。
d = \sqrt{1/2} \approx 0.7071 付近のグラフの比較は以下のようになります。

下図は改良された減衰関数を使った 3 次元シンプレックスノイズの xyz 方向の偏微分を重ね合わせたものです。こちらはズームしても不自然なラインは見当たりませんね。

シンプレックスの選択

次に、どのようにシンプレックスを選択するかを見てみます。

まずは簡単な 2 次元の場合について考えましょう。

これを前述したようにアフィン変換すると、以下のように各辺の長さが 1 の正方形でできた格子に斜線を引いたような図形になります。

各正方形の辺の長さは 1 ですので、座標の整数部からどの正方形に属するかが分かります。正方形は 2 つの三角形で構成されていますので、どちらの三角形かを求める必要があります。
この時、座標の小数部の (x, y) 成分のどちらが大きいか比較することで、どちらの三角形かを求められます。

斜めの線を式で表すと y = x であることに注目してください。

かしこい……!

そして、原点 (座標の整数部のみ) ・ +(0, 1) または +(1, 0) (上で求めたどちらか) ・ +(1, 1) の 3 点について処理を行います。


同様に 3 次元の場合について考えます。この場合も、アフィン変換を施すと立方体が並んだような状態になります。
したがって、座標 (x, y, z) の整数部からどの立方体に属すかが分かります。その立方体は 6 つの四面体で構成されています。

このとき、立方体を対角線から見た状態 (x = y = z の線上から見た状態) はこのようになるのですが、

6 つの三角形の領域に分割されているように見えると思います。これがちょうどそれぞれの四面体に対応しています。そして、 (x, y, z) 成分を大きい順に並べることで、どの領域 = 四面体に属するかがわかります。かしこい……!!

あとは、大きい順に単位ベクトルを足し込んでいきます。

具体例を挙げてみると、例えば座標 (1.234, 5.678, 9.012) だった場合、
座標の整数部は (1, 5, 9) 、小数部は (0.234, 0.678, 0.012) となります。小数部は y > x > z なので、まず原点となる (1, 5, 9) 、これに y 軸方向の単位ベクトルを足した (1, 6, 9) 、さらに x 軸方向の単位ベクトルを足した (2, 6, 9) 、最後に z 軸方向の単位ベクトルを足した (2, 6, 10) 、以上 4 点について処理すればいいことが分かります。


4 次元の場合も同様に、 (x, y, z, w) 成分を大きな順に並べ替えて得られた 24 パターンがそれぞれの五胞体に対応します。
より一般化すると、 n 次元において、座標の整数部で n 次元超立方体でできた格子を選び、 座標の小数点以下の各成分を大きな順に並び変えた n! パターンからそれぞれの超 n+1 面体を選択する、といった感じです。

さて、ここで勘のいい読者の方は気づかれたかもしれませんが、「並べ替え」、つまりソートが必要になります。
3 次元程度であればソートを使わなくても何度か比較をすれば OK ですが、 4 次元以上となるとさすがにソートを実装する必要が出てきます。

ソートは一般に O(n \log n) ですので、シンプレックスノイズのオーダーが O(n) であるとする主張は微妙に怪しくなってきますね。

せっかくなので、 4 次元版ではどのように処理しているかを説明します。

// offset0 は座標の小数部  
float2x4 order = { 0, 1, 2, 3, offset0 };  
  
// 座標の小数部 (order[1]) をキーとして、 order[0] と order[1] をソート  
order = order[1].x > order[1].y ? float2x4(order[0].yxzw, order[1].yxzw) : order;  
order = order[1].z > order[1].w ? float2x4(order[0].xywz, order[1].xywz) : order;  
order = order[1].x > order[1].z ? float2x4(order[0].zyxw, order[1].zyxw) : order;  
order = order[1].y > order[1].w ? float2x4(order[0].xwzy, order[1].xwzy) : order;  
order = order[1].y > order[1].z ? float2x4(order[0].xzyw, order[1].xzyw) : order;  
  
// 4x4 単位行列  
float4x4 unit4x4 = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };  
  
// 各点の原点からのオフセット  
float4 intOffset1 = unit4x4[order[0][3]];  
float4 intOffset2 = intOffset1 + unit4x4[order[0][2]];  
float4 intOffset3 = intOffset2 + unit4x4[order[0][1]];  

バッチャー奇偶マージソート を参考にしました。
小規模かつ定数個の (特に 2 冪個の) 要素に対して、少ない比較回数でしかも分岐 (if 文) を使わずに実装することができます。
if をあまり使いたくないシェーダーコードにはぴったりです。
実際に、前掲したコードでは 5 回の比較だけで実装ができています。

実際どういう感じで動作しているのか詳しく説明しましょう。

まず、 order は 2 つのベクトルからなり、 {0, 1, 2, 3} とソート対象の数値からなります。今回は 4 次元なので 4 要素ですね。
続いて、比較を行い、もし大きければその要素の交換を行います (Compare-and-Swap) 。
本来の順序 .xyzw から比較している要素の順序が入れ替わっていることに注目してください。
ここではソート対象の数値 (座標の小数部分) が昇順にソートされます。

ソートが終わったら、 order[0] つまり {0, 1, 2, 3} だったほうを参照します。
これはそれぞれ {x, y, z, w} に対応しており、 i 番目の要素が何だったかを知ることができるようになっています。
まずは一番右 order[0][3] を参照します。昇順にソートされているのでこれが一番大きい要素です。
これが 0 なら x が最大、 1 なら y が最大、…… といった感じに取得できます。
これをもとに単位行列からベクトルを取り出します。 0 なら {1, 0, 0, 0} (つまり x 軸方向の単位ベクトル) 、 1 なら {0, 1, 0, 0} (y 軸方向の単位ベクトル) 、…… が取得できます。

これも実例を挙げると、座標 (0.12, 3.45, 6.78, 9.01) について考えると、整数部は (0, 3, 6, 9) 、小数部は (0.12, 0.45, 0.78, 0.01) です。
小数部をソートすると z > y > x > w なので、原点 (0, 3, 6, 9) 、 +z 軸の (0, 3, 7, 9) 、 +y 軸の (0, 4, 7, 9) 、 +x 軸の (1, 4, 7, 9) 、 +w 軸の (1, 4, 7, 10) の 5 点について処理すればいいことが分かります。

ところで、上のコードでは 3 点ぶんしか求めていないのですが、残り 2 点は原点 (座標の整数部) と、原点に \vec{1} (全要素が 1 のベクトル) を足した座標で固定のためです。

コード

2 次元シンプレックスノイズの HLSL コードはこんな感じになります。
コードの分かりやすさを重視しており、最適化はほどほどにしてあります。

// 乱数テーブル  
static int permutation[512] = {  
151,160,137,91,90,15,  
131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,  
190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,  
88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,  
77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,  
102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,  
135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,  
5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,  
223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,  
129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,  
251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,  
49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,  
138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180,  
  
151,160,137,91,90,15,  
131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,  
190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,  
88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,  
77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,  
102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,  
135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,  
5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,  
223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,  
129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,  
251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,  
49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,  
138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180  
};  
  
// 勾配ベクトルの候補  
static float3 grad3vecs[] = {  
    float3(1, 1, 0),   
    float3(-1, 1, 0),  
    float3(1, -1, 0),  
    float3(-1, -1, 0),  
    float3(1, 0, 1),  
    float3(-1, 0, 1),  
    float3(1, 0, -1),  
    float3(-1, 0, -1),  
    float3(0, 1, 1),  
    float3(0, -1, 1),  
    float3(0, 1, -1),  
    float3(0, -1, -1),  
};  
  
// 指定座標の勾配ベクトルを取得  
float2 grad2(float2 pos)  
{  
    int p = permutation[((int)pos.x & 255) + permutation[((int)pos.y & 255)]];  
      
    return grad3vecs[(uint)p % 12].xy;  
}  
  
// 2 次元シンプレックスノイズ  
float simplex2(float2 pos)  
{  
    float skewConstant = 0.5 * (sqrt(3.0) - 1.0);  
    float unskewConstant = (sqrt(3.0) - 3.0) / 6.0;  
  
    // 正方形格子に変換し、三角形の頂点の一つを取得  
    float2 skewedIntpos0 = floor(pos + (pos.x + pos.y) * skewConstant);  
    // 実際の格子に戻す  
    float2 intpos0 = skewedIntpos0 + (skewedIntpos0.x + skewedIntpos0.y) * unskewConstant;  
    // オフセットベクトルを取得  
    float2 offset0 = pos - intpos0;  
  
    // △ (0, 1) か ▽ (1, 0) かを選択  
    float2 intOffset1 = offset0.x > offset0.y ? float2(1, 0) : float2(0, 1);  
  
    // 選択した点について、上と同じ処理を行う  
    float2 skewedIntpos1 = skewedIntpos0 + intOffset1;  
    float2 intpos1 = skewedIntpos1 + (skewedIntpos1.x + skewedIntpos1.y) * unskewConstant;  
    float2 offset1 = pos - intpos1;  
  
    // 対角線上の点 (skewedIntPos + (1, 1)) について、上と同じ処理を行う  
    float2 skewedIntpos2 = skewedIntpos0 + 1;  
    float2 intpos2 = skewedIntpos2 + (skewedIntpos2.x + skewedIntpos2.y) * unskewConstant;  
    float2 offset2 = pos - intpos2;  
  
    // 勾配ベクトルを取得  
    float2 gradient0 = grad2(skewedIntpos0);  
    float2 gradient1 = grad2(skewedIntpos1);  
    float2 gradient2 = grad2(skewedIntpos2);  
  
    // 各点の減衰係数 (tn) と値 (nn) を取得  
    float t0 = 0.5 - (offset0.x * offset0.x + offset0.y * offset0.y);  
    float n0 = t0 < 0 ? 0 : t0 * t0 * t0 * t0 * dot(gradient0, offset0);  
  
    float t1 = 0.5 - (offset1.x * offset1.x + offset1.y * offset1.y);  
    float n1 = t1 < 0 ? 0 : t1 * t1 * t1 * t1 * dot(gradient1, offset1);  
  
    float t2 = 0.5 - (offset2.x * offset2.x + offset2.y * offset2.y);  
    float n2 = t2 < 0 ? 0 : t2 * t2 * t2 * t2 * dot(gradient2, offset2);  
  
    // 各点の値の和に正規化定数を掛けて返す  
    return 70.0 * (n0 + n1 + n2);  
}  

正規化と最大値

さて、おもむろに最終結果に掛けられた正規化定数 70.0 はどこから来たのでしょうか。

正規化定数 (結果を \lbrack -1, +1 \rbrack の範囲に収めるために掛ける定数) を求めるためには、最大値 (最小値) を求める必要があります。
ここで、基本的にシンプレックスノイズは正負に対称なので、 最小値 = -最大値 となる……と仮定します。

最大値はどこでありえそうかというと、安直な推定としては、

  • 頂点
  • 辺の中心
  • 面の中心

ですが、まず頂点はあり得ません。というのも、オフセットベクトルが \vec{0} なので勾配ベクトルとの内積も 0 になりますし、ほかの頂点からの影響も距離減衰関数のためにほぼ 0 になるためです。

となると、まずは辺の中心です。勾配ベクトルとオフセットベクトルが完全に同じ方向を向いているときが最大で、オフセットベクトルの長さは \sqrt{2/3} / 2 = 1 / \sqrt{6} になるので、
2 \times (0.5 - (1 / \sqrt{6})^{2})^{4} \times (1 \times 1 / \sqrt{6}) = \sqrt{6}/243 \approx 0.01008 となります。

同様に、面の中心の場合は、オフセットベクトルの長さは \sqrt{2}/3 になるので、
3 \times (0.5 - (\sqrt{2}/3)^{2})^{4} \times (1 \times \sqrt{2}/3) = 625 \sqrt{2} / 104976 \approx 0.008420 となります。

したがって、辺の中心における最大値の逆数を取って、 243/\sqrt{6} \approx 99.20 を正規化定数とするのがよさそうです。

しかし、よく考えると勾配ベクトルを 12 パターンから選ぶ形式だった場合、単位ベクトルではないうえにオフセットベクトルと方向が一致するとは限りません。この場合について考え直してみましょう。

辺の中心の場合は、正方形の格子座標で (0, 0) の勾配ベクトルが (1, 1) 、 (1, 0) が (-1, 1) 、 (1, 1) が (-1, -1) のときが最大になりそうです。
このとき、 2 \times (0.5 - (1/\sqrt{6})^{2})^{4} \times \sqrt{2} \times 1/\sqrt{6} = 2 \sqrt{3} / 243 \approx 0.01426 となりました。

面の中心の場合は、正方形の格子座標で (0, 0) の勾配ベクトルが (1, 1) 、 (1, 0) が (-1, 1) 、 (1, 1) が (-1, -1) のときが最大になりそうです。
このとき、減衰係数 t_n は t_n = (0.5 - (\sqrt{2}/3)^{2})^{4} = 625/104976 になるので、 3 \times t_n (\sqrt{2} \times \sqrt{2}/3 \times \cos{30°}) = 625 \sqrt{3} / 104976 \approx 0.01031 となりました。

やはり辺の中心のほうが大きく、その逆数をとると 243 / 2\sqrt{3} \approx 70.15 となりました。おおっと、これかもしれないですね。
ただそう考えると、正規化定数 70.0 だと厳密に値域 \lbrack -1, +1 \rbrack にならない可能性がありますね。誤差の範囲ですが。

実際にそうなる座標を探してみたところ、以下の座標が見つかりました。補正前は正規化定数 70.0 を掛ける前、補正後は掛けた後です。

補正前 補正後 座標
最小値 -0.01425542 -0.9978796 (19.4159, 5.414753)
最大値 0.01425582 0.9979075 (81.3287, 80.3287)

これは実験値なので、より低い / 高い値がある可能性はなくはないですが、ほぼ (-1, +1) であり、 -1, +1 になる可能性はまずない、と言えそうです。


さて、今までは位置を推測してやっていたわけですが、勾配 (gradient, ∇) を求めることができればより正確に位置を把握できます。

勾配は、雑に説明するとその地点における x/y/z/w 軸方向への傾きの大きさです。
これが 0 になる地点は山の頂上か谷底 (または平地) ですので、最小値か最大値が存在する可能性があります。

具体的な求め方については次の項でやるとして、まずは図を見てみましょう。
勾配ベクトルを上で仮定した条件に固定したうえで、勾配が 0 になっている地点を表示したものです。

赤線が x 軸方向の傾きがほぼ 0 になっている地点、
緑線が y 軸方向の傾きがほぼ 0 になっている地点、
青線は格子です。

対角線上の白い三角形に注目すると、想像通り辺の中心と面の中心に赤線と緑線の交差するポイント = 最大値候補が存在します。その他にも角に近い方向にも最大値候補があるようです (実際の値は「辺の中心」よりも小さいものの) 。やはり確認してみるものですね。


さて、 3 次元と 4 次元では正規化定数は異なりますので、それぞれ調べてみましょう。

ただ理論値計算が面倒になってきたので、適当な解析スクリプトを書きました。
先に述べたように、勾配が分かればどこに最大値・最小値があるかが求めやすくなります。
凸凹の地面にビー玉を置いたら低いほうに転がっていくように、勾配の方向を辿っていけば最小値・最大値候補の地点があるはずですので、これをいろんな (ランダムな) 点に対して行えば最小値・最大値についてのデータが得られるはずです。

まずは 3 次元です。

しばらく計算させた結果、以下の結果が得られました。補正前は正規化定数 32.0 を掛ける前、補正後は掛けた後、座標はその値が得られた座標です。

補正前 補正後 座標
最小値 -0.031960905 -1.022749 (23.24482, 4.188703, 69.28975)
最大値 0.031990636 1.0237004 (2.369724, 92.91666, 38.46361)

微妙にオーバーしていますね……
この結果をもとにすると、正規化定数は 31.259147 になります。

次に 4 次元です。これの正規化定数は 27.0 でした。

補正前 補正後 座標
最小値 -0.037132673 -1.0025822 (6.269413, 78.189575, 20.220427, 87.21354)
最大値 0.037187822 1.0040712 (78.44264, 48.477448, 48.51413, 96.47745)

これも微妙にオーバーしていますね……
この結果をもとにすると、正規化定数は 26.890523 になりますね。

解析的な導関数

さて、最大値の計算に勾配を利用しましたが、この「勾配」というのは実質導関数です。
そういえば、シンプレックスノイズは解析的な導関数を求めるのが簡単だ、と特徴に書いてありました。実際に求めてみましょう。

まずは式に起こしてみましょう。最終的なノイズの値 n は、勾配ベクトル \vec{g_i} とオフセットベクトル \vec{x_i} を使って以下のように表せます。

  
n = \sum_i (0.5 - \vec{x_i} \cdot \vec{x_i})^4 \vec{g_i} \cdot \vec{x_i}

減衰関数の距離の 2 乗の部分は \vec{x_i} \cdot \vec{x_i} で計算できることに注意してください (同一ベクトルの内積は \vec{x_i} \cdot \vec{x_i} = |\vec{x_i}|^2 ) 。

とりあえず、単一の勾配ベクトルが与える影響について考えるため、 \sum はあとで考えることにします。複数の勾配ベクトルが与える影響を計算する場合でも、それらを加算するだけで済みますからね。

  
n_i = (0.5 - \vec{x_i} \cdot \vec{x_i})^4 \vec{g_i} \cdot \vec{x_i}

するとさしあたり、減衰関数と内積の二か所に分けるのがよさそうです。積の微分法則 (f(x)g(x))'=f'(x)g(x) + f(x)g'(x) より、

  
\nabla n_i = ( (0.5 - \vec{x_i} \cdot \vec{x_i})^{4})' \vec{g_i} \cdot \vec{x_i} + (0.5 - \vec{x_i} \cdot \vec{x_i})^{4} (\vec{g_i} \cdot \vec{x_i})'

まずは減衰関数の部分について考えてみます。一時変数 t_i を使って、

  
( (0.5 - \vec{x_i} \cdot \vec{x_i})^{4})' = (t_i^{4})'

とおくと、

  
\begin{align*}  
( (0.5 - \vec{x_i} \cdot \vec{x_i})^{4})' &= (t_i^{4})' \\  
&= 4 (t_i') t_i^{3} \\  
&= 4 (-2 \vec{x_i})t_i^{3} \\  
&= -8 \vec{x_i} t_i^{3} \\  
&= -8 \vec{x_i} (0.5 - \vec{x_i} \cdot \vec{x_i})^{3}  
\end{align*}

となります。

今度は内積のほうです。これは、

  
(\vec{g_i} \cdot \vec{x_i})' = \vec{g_i}

となります。以上を組み合わせると、

  
\begin{align*}  
\nabla n_i &= ( (0.5 - \vec{x_i} \cdot \vec{x_i})^{4})' \vec{g_i} \cdot \vec{x_i} + (0.5 - \vec{x_i} \cdot \vec{x_i})^{4} (\vec{g_i} \cdot \vec{x_i})' \\  
&= -8 \vec{x_i} (0.5 - \vec{x_i} \cdot \vec{x_i})^{3} \vec{g_i} \cdot \vec{x_i} + (0.5 - \vec{x_i} \cdot \vec{x_i})^{4} \vec{g_i}  
\end{align*}

となります。あとはこれを足し合わせて、

  
\begin{align*}  
\nabla n &= \sum_i -8 \vec{x_i} (0.5 - \vec{x_i} \cdot \vec{x_i})^{3} \vec{g_i} \cdot \vec{x_i} + (0.5 - \vec{x_i} \cdot \vec{x_i})^{4} \vec{g_i}  
\end{align*}

以上から、解析的な導関数を求めることができました。

より高次元の場合でもこれと同様で、足すべき点が増えるだけです。確かに簡単ですね!

パフォーマンス

パーリンノイズと同じようにパフォーマンスを測定してみました。

algorithm time(ms)
Simplex2 28466.9309 ms
Simplex3 51018.8530 ms
Simplex4 48797.2095 ms

これもなぜか 3 次元版だけ遅い結果となりました。どうして???

パーリンノイズと比較するとこのようになります。

同次元なら、シンプレックスノイズのほうが高速に描画できそうです。

問題

いろいろな問題を改善したシンプレックスノイズですが、まだいくつかの問題がある気がします。

モンスターボールのアーティファクト

2 次元シンプレックスノイズの画像をもう一度よく見てみましょう。

よく見てみると、モンスターボールのような、円形を半分に割って片方を黒に・もう片方を白に塗った模様で埋め尽くされているように見える気がしませんか?

勾配ベクトルによる影響が直線的であることと、減衰関数が円形であることからこのような模様になっているものと思われます。

異方性が残っている

シンプレックスノイズは目に見える異方性がない、とされていますが、実際に上の画像を見てみると、三角形タイルまたは六角形タイルをベースにしたような、 60° ごと・ 120° ごとに走る直線のようなものが見えるかと思います。

減衰関数は円形なのでそういう意味での異方性はなくなったのですが (パーリンノイズでは補間による四角形の異方性がありました) 、格子に由来する異方性は依然として残っています。

これに関しては、 "Domain Rotation" と呼ばれる手法を用いることで大幅に軽減することができます。

Domain Rotation については、 https://noiseposti.ng/posts/2022-01-16-The-Perlin-Problem-Moving-Past-Square-Noise.html に詳しい説明がありますがこちらでも説明します。

今までのノイズ生成においては、基本的に描画面と格子は直交しているものとしてきました。それはそう。
これを回転させることによって、異方性として感じられやすい格子が見えにくくなり品質の向上が見込まれます。言葉で説明するとわかりにくいと思うので、以下の図を見てみてください。

Domain Rotation なし Domain Rotation あり

赤い面が描画面だとしてください。今までは左図のように格子に垂直に描画していたのですが、右図のように回転させて描画することで格子の影響を目立たなくします。

赤い面 (描画面) に実際映るのはどんな感じかというと、下図のようになります。

Domain Rotation なし Domain Rotation あり

四角形の規則正しい格子が歪められて、結晶構造が分かりにくくなっているかと思います。

実際に、 3 次元シンプレックスノイズに Domain Rotation を適用してみた図をご覧ください。

Domain Rotation なし Domain Rotation あり

ちょっとわかりにくい気もしますね……
それでは、わかりやすいバリューノイズで試してみましょう。

Domain Rotation なし Domain Rotation あり

これはかなりわかりやすく改善している気がしますね。格子感が軽減されている気がします。

Domain Rotation の利点は、簡単な座標変換をノイズ計算の前に挟むだけなのでパフォーマンスへの影響が小さく、かつノイズ計算自体には手を入れないのでどのようなノイズ手法にも適用可能なことです。
欠点をあえて挙げるとすると、基本的に 3 次元以上でのみ適用可能なところです (例えば、 2 次元に回転を導入しても普通に回るだけなので、格子構造は隠れません) 。
ともあれ、低品質なノイズの場合はかなり改善が見込まれる、画期的な手法です。

ところで、高次元での計算が必要になると、以下の問題が発生します。

高次元において品質が下がる

まずは画像を見てください。 2・3・4 次元のシンプレックスノイズの画像です。

2 次元 3 次元 4 次元

次元が増えるにしたがってグレーの (数値として 0 に近い) 領域が増えていっているのがおわかりいただけるでしょうか。

体感だけではいけないのでヒストグラムをとってみると、

2 次元 3 次元 4 次元

正負ともに極端な値が減り、全体的に中心 (0) に近づいていることが分かります。

比較のため、各次元のパーリンノイズの画像はこのようになります。

2 次元 3 次元 4 次元

パーリンノイズは高次元でもグレーの領域が増えたりせず、概ね同様の見た目になっています。

2 次元 3 次元 4 次元

ヒストグラムも概ね同じ形ですね。

この問題の原因としては、高次元になるにしたがって歪んだ形の図形になるため (2 次元では正三角形でしたが 3 次元では細長く歪められた三角錐でしたね) 、勾配ベクトルの効果が届きにくいエリアが増えていくから、というのが考えられます。

安直にタイル化できない

パーリンノイズでは、両端の勾配ベクトルを同一にすることで簡単にタイル化 (タイルのように並べたときに継ぎ目ができないように) することができました。
シンプレックスノイズでも同じ方法ができますが、タイルの形が三角形なので、一般の四角形タイルとしてタイル化することができません。

パーリンノイズ(a) パーリンノイズ(b) シンプレックスノイズ(a) シンプレックスノイズ(b)

シンプレックスノイズも一見うまくいっているように見えますが、この画像をタイルとして並べても上下左右がつながっていないのでダメです。

この問題については、格子構造を変えることによってタイル化を可能にしている論文があります。 *4
この論文の実装としては、 Unity の Mathematics パッケージ の psrnoise および psrdnoise があります。

あとは 3 次元シンプレックスノイズを使って、円柱またはトーラスの表面の座標をもとに値を取得する手もあるそうです。
1 次元上のノイズが必要なので微妙に見た目が変わる問題はあるものの、なんとかなりそうではあります。

余談:その他のノイズ

バリューノイズ

時々話題に出していたバリューノイズですが、これはとても単純です。
パーリンノイズと同様に四角形 (立方体) の格子を作り、そこで勾配ベクトルの代わりに乱数 (スカラー) を生成します。あとはパーリンノイズと同様に補間すれば完成です。

3 次元バリューノイズ

ベクトルじゃなくてスカラー (値) なのでバリューノイズです。

さすがに四角形が目立つのでそのまま使うのは難しそうですが、実装が簡単なので時々日の目を見ます。

OpenSimplex

シンプレックスノイズの一部の実装は、アメリカで特許が取られていた時期があります (現在は期限切れ) 。
それを回避するために OpenSimplex およびその改良版である OpenSimplex2 が開発されました。

格子構造が微妙にシンプレックスノイズとは異なっているそうです。
詳しくは調べられていないので紹介にとどめます。

見た目的にはほぼシンプレックスノイズと変わらないので、プロジェクトに新しく導入するならこれな気がします。
(ただし、若干計算コストが上がっているみたいな話を聞いた覚えはあります。)

fBm

fBm (Fractal Brownian Motion) は、複数のノイズを組み合わせてより高品質なノイズを得る手法です。

簡単な作り方としては、

  1. 元となるノイズを用意する
  2. 1 に「周波数が 2 倍で振幅が 1/2」なノイズを足す
  3. 満足いくまで 2 を繰り返す

という感じです。

2 次元シンプレックスノイズで fBm を行うとこのようになります。

周波数 4, 振幅 1/2 周波数 8, 振幅 1/4 周波数 16, 振幅 1/8
周波数 32, 振幅 1/16 周波数 64, 振幅 1/32 総和

「総和」はペイントツールでよく見る「ノイズ」になっているのではないでしょうか。

ここで、周波数の倍率と振幅の倍率を調整すれば、別の味付けがされた (?) ノイズを得ることもできます。

カールノイズ

カールノイズ (Curl Noise) はノイズの応用手法で、流体の動作を擬似的に再現できます。 *5

まずはノイズからベクトル場 \vec{\psi} = (\psi_x, \psi_y, \psi_z) をつくって、その回転 (rot, curl, \nabla \times) をとります。

  
\nabla \times \vec{\psi} = \begin{pmatrix}  
\frac{\partial \psi_z}{\partial y} - \frac{\partial \psi_y}{\partial z} \\  
\frac{\partial \psi_x}{\partial z} - \frac{\partial \psi_z}{\partial x} \\  
\frac{\partial \psi_y}{\partial x} - \frac{\partial \psi_x}{\partial y}  
\end{pmatrix}

\frac{\partial \psi_x}{\partial y} は、雑に言えば y 軸方向にちょっとだけ動いたときの \psi_x の変化量です。

そして、この結果を速度として、各パーティクルを動かします。
ちなみに、 2 次元なら以下のように単純になるらしいです。

  
\nabla \times \psi = \begin{pmatrix}  
\frac{\partial \psi}{\partial y} \\  
-\frac{\partial \psi}{\partial x} \\  
\end{pmatrix}

このように定義すると、回転 \nabla \times \vec{\psi} の発散が \nabla \cdot \nabla \times \vec{\psi} = 0 と常に 0 になるのがポイントらしいです。
発散が 0 ということは、雑に言えば水槽の中に入った水や風船の中の気体のように、突然どこかから水が湧いたり吸い込まれたりしない状態を表します。
この発散が常に 0 になるようなベクトル場を Divergence-Free Vector Field (DFV) というそうです。
日常で見るような (常温常圧で極端に高速でない) 空気や水はほぼこの状態なので、それと同じような挙動をシミュレートすることができます。

DFV をどうやって作るのかというと、適当なノイズ (パーリンノイズでもシンプレックスノイズでも、あるいは別の何かでも可) を使って、適当な距離離れた (相関しない) 点から拾ってベクトル場をつくります。そうしたら前述の式を使って DFV に変換します。
3 次元シンプレックスノイズを使った実装例はこんな感じです。

float3 curl(float3 pos)  
{  
    float delta = 0.001;  
    // 適当な距離離れた 3 点  
    float3x3 v = float3x3(pos, pos + float3(123.456, 789.012, 345.678), pos + float3(901.234, 567.890, 123.456));  
  
    // ∂i/∂j → j 軸上で微小距離 (2Δ) 離れた点から値をとった時の i 軸上の差分   
    float pxpx = (simplex3(v[0] + float3(+delta, 0, 0)).w - simplex3(v[0] + float3(-delta, 0, 0)).w) / (2 * delta);  
    float pxpy = (simplex3(v[0] + float3(0, +delta, 0)).w - simplex3(v[0] + float3(0, -delta, 0)).w) / (2 * delta);  
    float pxpz = (simplex3(v[0] + float3(0, 0, +delta)).w - simplex3(v[0] + float3(0, 0, -delta)).w) / (2 * delta);  
    float pypx = (simplex3(v[1] + float3(+delta, 0, 0)).w - simplex3(v[1] + float3(-delta, 0, 0)).w) / (2 * delta);  
    float pypy = (simplex3(v[1] + float3(0, +delta, 0)).w - simplex3(v[1] + float3(0, -delta, 0)).w) / (2 * delta);  
    float pypz = (simplex3(v[1] + float3(0, 0, +delta)).w - simplex3(v[1] + float3(0, 0, -delta)).w) / (2 * delta);  
    float pzpx = (simplex3(v[2] + float3(+delta, 0, 0)).w - simplex3(v[2] + float3(-delta, 0, 0)).w) / (2 * delta);  
    float pzpy = (simplex3(v[2] + float3(0, +delta, 0)).w - simplex3(v[2] + float3(0, -delta, 0)).w) / (2 * delta);  
    float pzpz = (simplex3(v[2] + float3(0, 0, +delta)).w - simplex3(v[2] + float3(0, 0, -delta)).w) / (2 * delta);  
  
    return float3(pzpy - pypz, pxpz - pzpx, pypx - pxpy);  
}  

または、解析的な導関数を求めることができているなら、こういう簡単な実装もできます。

float3 curl2(float3 pos)  
{  
    // 適当な 2 点の勾配 (xyz) を得る  
    float4 a = simplex3(pos);  
    float4 b = simplex3(pos + float3(123.456, 789.012, 345.678));  
  
    // クロス積をとる  
    return cross(a.xyz, b.xyz);  
}  

なぜかというと、
まず、導関数はすなわち勾配のことなので、 \nabla a, \nabla b は既知です。
このとき、

  
\nabla \cdot (\vec{A} \times \vec{B}) = \vec{B} \cdot  (\nabla \times \vec{A}) - \vec{A} \cdot (\nabla \times \vec{B})

という公式があります。
この公式がどうして成り立つのかというと、

  
\begin{align*}  
& \nabla \cdot (\vec{A} \times \vec{B}) \\  
&= \frac{\partial}{\partial x} (A_y B_z - A_z B_y) + \frac{\partial}{\partial y} (A_z B_x - A_x B_z) + \frac{\partial}{\partial z} (A_x B_y - A_y B_x) \\  
&= \frac{\partial}{\partial x} A_y B_z - \frac{\partial}{\partial x} A_z B_y + \frac{\partial}{\partial y} A_z B_x - \frac{\partial}{\partial y} A_x B_z + \frac{\partial}{\partial z} A_x B_y - \frac{\partial}{\partial z} A_y B_x  
\end{align*}

ここで、 \frac{\partial}{\partial \gamma} \alpha \beta = \frac{\partial \alpha}{\partial \gamma} \beta + \frac{\partial \beta}{\partial \gamma} \alpha となるので、

  
\begin{align*}  
& \nabla \cdot (\vec{A} \times \vec{B}) \\  
&= \frac{\partial A_y}{\partial x} B_z + \frac{\partial B_z}{\partial x} A_y - \frac{\partial A_z}{\partial x} B_y - \frac{\partial B_y}{\partial x} A_z + \frac{\partial A_z}{\partial y} B_x + \frac{\partial B_x}{\partial y} A_z \\  
&- \frac{\partial A_x}{\partial y} B_z - \frac{\partial B_z}{\partial y} A_x + \frac{\partial A_x}{\partial z} B_y + \frac{\partial B_y}{\partial z} A_x - \frac{\partial A_y}{\partial z} B_x - \frac{\partial B_x}{\partial z} A_y \\  
&= B_x (\frac{\partial A_z}{\partial y} - \frac{\partial A_y}{\partial z}) + B_y (\frac{\partial A_x}{\partial z} - \frac{\partial A_z}{\partial x}) + B_z (\frac{\partial A_y}{\partial x} - \frac{\partial A_x}{\partial y}) \\  
&- A_x (\frac{\partial B_z}{\partial y} - \frac{\partial B_y}{\partial z}) - A_y (\frac{\partial B_x}{\partial z} - \frac{\partial B_z}{\partial x}) - A_z (\frac{\partial B_y}{\partial x} - \frac{\partial B_x}{\partial y}) \\  
&= \vec{B} \cdot (\nabla \times \vec{A}) - \vec{A} \cdot (\nabla \times \vec{B})  
\end{align*}

となるためです。
これに \vec{A} = \nabla a, \vec{B} = \nabla b と代入すると、

  
\nabla \cdot (\nabla a \times \nabla b) = \nabla b \cdot (\nabla \times \nabla a) - \nabla a \cdot (\nabla \times \nabla b)

\nabla \times \nabla a = \nabla \times \nabla b = \vec{0} ですので、

  
\nabla b \cdot (\nabla \times \nabla a) - \nabla a \cdot (\nabla \times \nabla b) \\  
= \nabla b \cdot \vec{0} - \nabla a \cdot \vec{0} \\  
= 0

したがって、 \nabla a \times \nabla b の発散は 0 となり、めでたく Divergence-Free Vector Field を得ることができました。


以上で得られたカールノイズをパーティクルの速度に適用するとこんな感じになります。

たのしい!

余談

本記事の執筆中に踏んだバグとして、気を付けておきたいのがひとつ……

3 次元以上のノイズを実装する際、 pos に (uv, time) とすることはよくあることかと思います。
が、しばらく (1 日ぐらい) 経過するとなんか計算が合わず、何度見直してもコードに不具合はなく、そして再起動すると何事もなかったかのように直る、という怪奇現象が発生していたことがあります。

各種ノイズはシェーダで計算する以上 float 精度になるのですが、 float なので time が大きくなると精度が落ちて計算結果が狂う問題があります。
1 日は 86400 秒なので 86400 > 2^{16} 、 float の精度は 24 bit なので、小数点以下の精度が 8 bit 分 ( \approx 0.004 単位) 以下しか残らなくなります。

座標として 86400 を指定するようなことはほとんどないと思いますが、時間としてなら割と現実的にありうる数値なので、そういうバグを踏まないように (踏んだら再起動するように) してください……

おわりに

パーリンノイズとシンプレックスノイズの動作原理や、その応用について触れました。
今まで「よくわからんけど動く!」としてきた方も、この記事で「ノイズちょっとわかる」状態になったとすれば嬉しいです。

本稿で実装したノイズは以下の gist に載せてあります。私が工夫したところはないので CC0 です。

Perlin and Simplex noise implementation

また、 Shadertoy で実際動いているところを見ることもできるようにしました。こっちは GLSL です。
本家に飛ぶとコードをいじって遊べるので試してみてください。

Perlin/Simplex noise sample

GLSL しか対応していないとは知らず、 HLSL からの移植作業を頑張る羽目になったのは内緒です。

文献など

ノイズを調べるうえで役立つ文献のリストを載せておきます。

Modifications to Classic Perlin Noise | briansharpe

パーリンノイズを改造して見た目を良くする試みです。
補間をシンプレックスノイズと同様の減衰関数に置き換えた Classic Perlin Surflet Noise, 加えてランダムなオフセットを加えて格子を見えにくくした Classic Perlin Surflet Offset Noise が提案されています。

The Book of Shaders: Noise

ノイズとその利用法について、実際に動くシェーダーコードを交えてわかりやすく解説されています。

The Book of Shaders: Fractal Brownian Motion

fBm についての解説です。

Noise hardware

シンプレックスノイズの原著論文です。

Simplex noise demystified

シンプレックスノイズの解説論文です。詳しく知りたい場合は原典より頼りになりそうです。
(曰く、原典は最適化されたコードになっていて理解の助けにはなりにくいとのこと)

Improving Noise

改良パーリンノイズについての論文です。
補間式を五次にした点と、勾配ベクトルの選び方を完全ランダムから正方形の辺上の点に変えた点が記載されています。

Improved Noise reference implementation

Perlin 氏が書いた改良パーリンノイズのソースコードです。

An Image Synthesizer

パーリンノイズの原著論文です。

Tiling simplex noise and flow noise in two and three dimensions

シンプレックスノイズをタイル化可能にしたり、減衰関数の欠点を改善したりしている論文です。

Curl-Noise for Procedural Fluid Flow

カールノイズの原著論文です。

Divergence-free noise

カールノイズの \nabla \cdot (\nabla a \times \nabla b) = 0 をもとに生成するほうの論文です。

stegu/psrdnoise: Tiling simplex flow noise in 2-D and 3-D compatible with GLSL 1.20 (WebGL 1.0) and above.

タイル化可能シンプレックスノイズのコードリポジトリです。
関連する論文も含まれているので気になる方は参照してみてください。

Curl Noise

カールノイズの応用で、炎のゴーレムを作っています。

Unity Pseudorandom Noise Tutorials

バリューノイズ・パーリンノイズ・シンプレックスノイズなどの実装が詳細に説明されています。

Pseudorandom Surfaces

パーリンノイズやシンプレックスノイズの微分について詳細に説明されています。

NoisePosti.ng

OpenSimplex(2) の著者のサイトです。シンプレックスノイズの応用についてや、パーリンノイズの使用を回避すべき理由について述べられています。

Inigo Quilez :: computer graphics, mathematics, shaders, fractals, demoscene and more

パーリンノイズの微分についてです。全部展開されているのでちょっと読みにくいですが……

Auburn/FastNoiseLite: Fast Portable Noise Library - C# C++ C Java HLSL GLSL JavaScript Rust Go

高速な各種ノイズの実装があるリポジトリです。

image processing - Why does increasing simplex noise dimension wash it out? - Computer Graphics Stack Exchange

シンプレックスノイズが高次元において品質が下がっていく問題についての質問です。

Demos and tutorials

psrdnoise (タイル化可能なシンプレックスノイズ) のデモとチュートリアルページです。
その論文の著者が直々に作成したものです。

*1:Perlin, K. (1985). An image synthesizer. ACM Siggraph Computer Graphics, 19(3), 287-296.

*2:Ken Perlin, Noise hardware. In Real-Time Shading SIGGRAPH Course Notes (2001), Olano M., (Ed.).

*3:Gustavson, Stefan, and Ian McEwan. "Tiling simplex noise and flow noise in two and three dimensions." J Comput Graph Tech 11.1 (2022).

*4:Gustavson, Stefan, and Ian McEwan. "Tiling simplex noise and flow noise in two and three dimensions." J Comput Graph Tech 11.1 (2022).

*5:Bridson, Robert, Jim Houriham, and Marcus Nordenstam. "Curl-noise for procedural fluid flow." ACM Transactions on Graphics (ToG) 26.3 (2007): 46-es.

【まもけん2】村長の試練2 RTA メモ

まえがき

筆者はまだ通せてません!!!!(300敗)
説得力がないけど、 100 回負けて放置してから戦略を忘れてもう 200 回負けたので今度は忘れないようにメモしておく。

階層によらない戦略

料理は「生きているエビ」(攻撃力+25)一択。階段運が良ければ最終盤まで維持できる。

基本的には即降り。
民家や狩れるモンハウがあったなどの理由がない限り、階段を見つけ次第降りる。

i ダッシュと通常ダッシュを使い分けられるようにしておくとよい。
筆者は X で i ダッシュ、 R2 で通常ダッシュに設定している。
i ダッシュのほうが通路の移動が速いが、部屋内で思った位置に行けない場合があるため。

クラフト素材は無視してかまわない。
ただしこれは人によりそう、うまく回収できれば最終盤で明かりの本 (リーネル布/繊維草/星の欠片/月の欠片) が作れるので有利。

杖の識別を怠らないこと!持っているだけでは緊急時に使えない。あと呪いケアも大事。

開幕モンハウ対策を常にひとつは持っておくこと。瞬間移動の果実・集団転移の本、眠りの魔導書 etc.
99F もあれば一度は開幕モンハウを踏むことになる。上記がなくて死ぬことも多々ある。

ポーチにうまいことパンを入れておくこと。とくに弱化のポーチや果実拡散のポーチがよさげ。
食糧難になることが多いので、パンは一個たりとも無駄にしない精神で行きたい。

階層別

1-4 F

スライム・イエロースライム
3F~ ハニービー・アルラウネ・マンドラゴラ

ここではドロップアイテムが限られており、識別がしやすい。

武器・防具は弱いか印がないもので、敵はまだ素手で対処可能なので、装備は後回しにするのも手(呪いケアのため)。
木箱オブジェクトを壊すと確定で「つるはし」がドロップするので、必要なら拾っておく。修正値を増やすためにも使えるかもしれない。

腕輪は「ちからの腕輪」「ただの腕輪」のどちらかなので、装備してメニューを開き「ちから」にプラス値があるかどうかで識別可能。大抵の場合「ちからの腕輪」。

本は回数制で「どれを?」タイプなら「識別の魔法書」確定。とっておいて後々腕輪かポーチに使う。
回数制でない「どれを?」は「武器強化の本」「防具強化の本」「メッキの本」のどれか。メッキで錆印が付いてしまうと合成時に困る可能性があるため慎重に。また、強化系の本で呪いを解くことができるので、後々アルファ産の武器防具が呪われている可能性を考えてとっておくのもよい。
それ以外の回数制の場合は「回復の魔法書」「招集の魔導書」か。
ほかは「明かりの本」「迷いの本」「倍速の魔導書」「鈍足の魔導書」。
迷いケアで階段を降りた直後に読むと無難。もっとも被害は少ないので即読みでもよい。

ここで拾える杖は「強化の杖」「弱化の杖」「毒の杖」「混乱の杖」「吹き飛ばしの杖」あたり?
「毒の杖」は対アルファ用に使える。 3 回振れば必ず HP 1 にできる。
アルラウネを吹き飛ばしで怒らせないよう注意。

ハニービーに刺されると永続的にちからが減るのでなるべく刺されないように。

パン類は貴重なので迂闊に食べないこと。クラフトでパンx2→大きなパンは問題ないが、大きなパン→巨大なパンは回復量がほぼ半減するので注意。

果実は「小さい回復の実」「鈍足の果実」「倍速の果実」「瞬間移動の果実」あたり。複数拾えたやつが多分「小さい回復の実」。
鈍足無効の合成を狙う場合はここで拾った果実を食べずにとっておくのも手。

高額のお金が落ちていることがある。 上に乗って 700G~ なら拾うことでインベントリに保持し、対アルファ最終兵器として使う。

5-8 F

フェアリー・エアリアル・マイコニド・ワーバット

ここからアルファが出現する。このアルファがクリアのカギとなるレベルで重要。
主力級の武器防具を落とす可能性があるため、積極的に狩りに行く。狩られたら再走。

うれしい武器称号は「孤高の」「すばらしい」「本当に凄い」「波動の」「通路好きの」「魔術師の」あたり。火力増加系で常時発動型 or 条件がゆるいものだとよい。

また、「一点集中の」を拾った場合はワンチャンチャートに切り替える?
火力が 2 倍 (!) なので、大抵の敵をワンパン可能になる。 2 印もつけられれば最強だが若干オーバーキルか。
もちろん脆いのですぐ死ぬ。灯印と組み合わせると先制されにくくなりおすすめ。
この場合戦闘用仲魔との協力が不要になるので勧誘しなくてもよくなり、高速化につながる。
それでもふつうに盾は用意しておくと便利か。最終盤は火炎・鈍足耐性がないと生きていけないので。
一応盾なしで 2 時間以内にクリア可能なことは確認しているので有力……かもしれない。

序盤に限るが「凍てついた」もよい。13F~のエリアで役立つ。

武器スキルは「一文字斬り」「全員斬り」「爆炎剣」「氷結剣」のどれかがあると嬉しい。
「一文字斬り」は移動用。開幕モンハウから脱出する、アイテム浮島に移動するなど。3 マス先まで塞がれていると移動に失敗するので注意。
「全員斬り」はモンハウの掃討用。ただし一撃では倒せないことが多いので二の矢が欲しい。
「爆炎剣」は敵を消滅させられるので強い(カソは耐性持ちなので注意)。爆破耐性と一緒に欲しい。
「氷結剣」はダメージ純増・一時的な壁の作成・「回復の杖」「倍速の杖」の反射など。
それ以外では「無明」でモンハウの敵を同士討ちさせつつ逃げる、「魔法陣転移」を階段前に置いて探索続行・泥棒するなど。「天照」はまだ見たことない。

あとは基本値+、印+、状態異常付与があるとなおよい。
この基本値+が侮れない、場合によっては「すごい」称号より高い修正が入ることもある。
終盤の敵に対しては攻撃力 2 = 1 ダメージなので、もし +10 なら +5 ダメージ増える。

うれしい防具称号は「火事場の」「活力の」「枕靴下の」「回復導師の」「金溶の」「魔術師の」「孤高の」「爆森人の」「臆病者の」あたりか。
「金溶の」はダメージ -10 。固定値減少なので特に序盤で強力。ただし消費しすぎると仲魔勧誘が難しくなる場合があるので注意。
「爆森人の」があれば爆破耐性を入れなくてもよくなる。
「回復導師の」は回復リソースが確保できるほか、未識別の本を識別できるため嫉妬ケアにも使える。ただ集団転移や状態異常本が使えなくなるのでサブ盾運用推奨。あと呪いケア必須。
「臆病者の」はテレポ逃げしやすくなるので強力だが、誤操作が多い印象。使いこなせればつよい……

あとは同じく基本値+、印+、状態異常耐性(特に鈍足)があるとなおよい。
印枠が少ないので剣より印+の恩恵が大きい。基本値が低くても印 5 なら使えるかも。

視界が良いため、見渡しで階段やアルファの位置を確認できる。特にアルファが誰かを知るのは大事。
フェアリーは杖無効だが濁酒やお金投げが効く。倍速飛行なので移動ルート予測が若干しにくい。
ワーバットは混乱(濁酒)無効のため注意。ふらふら移動なので矢でヒットアンドアウェイするなど。
マイコニドは対策が効きやすく地上型なのでルートも予測しやすい。ありがたい。
なお、プロマイコニドは一撃で倒せないことがあるので注意。
エアリアルだった場合は例外的に逃げも考慮、対策アイテムがない限り勝ち目が薄いため。
投擲無効かつ倍速飛行なので現時点で取れる対策が倍速の杖反射・鈍足の杖・毒の杖・赤水晶か緑水晶の活用ぐらいしかない。

また、ここから床落ちアイテムテーブルが変わる。「ビーストキラーィ」など特効系武器や「会心の腕輪」「遠投の腕輪」なども登場し始める。腕輪はなるべく拾っておく。

9-12 F

ポイズンスライム・見習いエルフ・ラミア・グリフォン・ワーウルフ・変化キツネ

民家や店が出現し始める。民家では合成を進めたり本をもらったりする。
合成は印だけでなく修正値を上げる意味でも重要。
特に青釜だった場合、剣に混乱・睡眠・麻痺攻撃、盾に鈍足無効をつけるチャンス。鈍足無効は生命線になるので拾えていたらつけておきたい。

また、不要な装備が呪われている場合は、別の装備→呪われた装備、の順で合成すれば外せる。(別の装備が呪われていない限り)
一応腕輪の合成識別(装備中の腕輪を合成すると識別されるテク)も可能だが、今後高い腕輪の個数が重要になってくるので良し悪し。個人的にはやらずに「ちからの腕輪」で進むほうがいい気がする。もちろん「ちからの腕輪」が複数拾えている場合は合成したほうが良い。
ちから +3 は攻撃項 +4.5 なので、 2~4 ダメージ程度増える。 +6 なら +4~9 ダメージ。

剣の印の優先度は、現時点では
会心・(バール)>回復・マナ>状態異常印>種族特効>(サビ)
バールはまだ床落ちしていない。今後 2 回攻撃印を入れたりするので枠を開けておく。
マナ印(エルフの細剣)は店売りしていることがある。 MP がカツカツになりがちなのであると嬉しいが 6000G するので財布と相談。
回復印は「回復の剣」が売られていた場合に考慮。 HP +5 は侮れない。「回復の実」などを合成するのはちょっと枠がもったいないか。

種族特効は個人的には 有鱗・有翼・悪魔・獣>冷血・虫・魔法生物>(その他=入れたくない)。
有鱗はタフでやばい敵が多く、「ドラゴンキラーィ」自体も優秀なため。
有翼はグリフォン系・フクロウ系などタフで終盤でも出てくるのの対策。
悪魔はやべーやつが多い。
獣は対象範囲が広い。
冷血もタフ。ただし対象キャラが少ないか?
虫は対なめくじ系・アルケニー系。ピンポイントすぎるか?
魔法生物はガーゴイル用。あと「砕きのハンマー」の基本値が優秀なため。
それ以外は対象が少ないか、弱くて特効を入れるほどでもない。

サビ印は貴重な枠を一つ占有するので微妙。ただ 21F~ のスラグ対策にはなるのでむずかしいところ。あとで消す前提でサブ武器に仕込むならあり?
最近は入れていることが多いかも。 RTA だと枠が埋まるほど合成できないこともあるから……
他のゲームと異なり、サビ印は複数つくので注意。こんぼう合成後にメッキを読むと枠がつぶれて厳しい。

盾の印の優先度は、
鈍足・炎>氷・重装>爆発>(サビ)
鈍足無効は非常に重要:対リザード系・アルケニー系。出現ゾーンが広いうえ致命的になりがち。ただし「鈍足よけの腕輪」が拾えた&識別できた場合は他の印のために入れない選択もあり。
炎印はダメージ減よりアイテム焼失を防ぐために必要。30F~ の火炎ゾーン前には欲しい。最終盤でも役立つ、というかないと焼き尽くされて終わる。
氷印はハメ殺しに遭わないために重要。ただ 13F~ の氷ゾーンには間に合わないことが多い。ただ 89F~ にも氷ゾーンがあるので無駄にはならない。
ただちょっとピンポイント感はあるので、サブ盾(を持つ余裕があれば)にする手はある。
重装印はオーガ系の事故防止に。早く合成しすぎると飢え死にする可能性があるのでタイミングを見計らって入れる。
爆発耐性は「爆炎剣」の入った剣が確保できた場合に有効。「爆発のポーチ」も使いやすくなる。ただ積極的に入れるほどではないかも。
サビ印は剣と同様で一長一短。ただ盾は印枠が足りないので入れにくさが増している。
それ以外では、風の加護は MP を消費してしまうので微妙。弾かれずは邪魔(ベース盾としては良いかも)。

民家で ❤3 以上なら本棚から本をもらえる。
未識別のまま読む場合、集団転移(泥棒)・炎や風(ダメージによる敵対)の可能性があるので注意。横着せずに許可を取ってから読んだほうが安全。
回数制の本の場合、炎で釜の合成回数を復活させられる可能性がある。釜に向かって読むとよい。(氷だと釜の火が消えてしまうので合成後にやること)

ここも見渡しが有効。外壁の切れ目が階段。あと民家や店・モンハウ(光る宝箱)の早期発見にもつながる。開幕で周囲を見渡して計画を立てよう。

モンハウは開幕でなければ楽勝なので積極的に狙う。物資と経験値を確保しよう。
魔力珠は 5000G で売れる。店が同じフロアにあればラッキー。
開幕モンハウの場合はがんばるか諦める。

このあたりから「呪いの本」が落ちていることがあるので、強化だと思って現在の装備に読まないように(n敗)。

この時点で一撃で倒せない可能性があるのはラミアとグリフォン。
ラミアは HP 48、グリフォンは HP 50。

ラミアの叩きつけに注意。実質 2 回攻撃なので HP に余裕を持つこと。
もっとも、この時点で死んでもあまり痛くはないので、装備がしょぼい場合はワンチャンしてもいい(?)
グリフォンは耐久が高いうえ、連れ去られた先がモンハウという事故もなくはない(2敗)ので早期警戒を怠らない。

13-16 F

スライム・フクロウ・ワーラビット・ユキンコ・ワーキャット

氷ゾーン。満腹度が減りやすい。ここで大体最初のパンを食べることになる。

ユキンコが特に注意。 HP が 50 程度あり一撃で倒せないうえ、凍結でこちらの HP を半分程度持っていく。絶対零度の結界も厄介。
逆に、もし一撃で倒せる火力があるなら記録ワンチャンある。
そうでなければ矢や封印の杖・トンネルの杖(20ダメージ)などを活用する。
ユキンコがいるので、このエリアのモンハウには入らないほうがいい。
出入り口にアイテムや眠っている敵がいた場合は見渡してチェックする。

ワーラビットに注意。視界外からの攻撃に気づかず死ぬことがある。(n敗)

とにかく即降りすること。長居していいことはない。
一応、時々民家や店が出現する。

17-20 F

ガーゴイル・バンシー・フェンリル・ホブゴブリン・エルフ

フェンリルが純粋に強い。説得するとうまあじ。
モンハウに行く場合はバンシー(壁抜け)に注意。後ろに下がれるか確認しておくこと。
ホブゴブリンと戦うときは水路を背にしないこと。腕輪が持っていかれる。

縦長の広いマップなことが多い。一番下のあたりに行くと謎の 💤 や Lv. アイコンがあり、民家やモンハウの存在を察知できることがある。

このゾーンを抜けるまでに、 5000G の腕輪を 2 個以上、財布に 5000G 以上ある状態にしておくとよい。

21-24 F

変化キツネ二尾・ジギタリス・スロウビー・スラグ・モス・ファクシー

攻略のカギとなるエリア。
必ずプロのモスを仲魔にすること。できれば 2 体。
「みんなげんきになあれ」で回復する用。 1 体だと SP が足りなくなることが多いので 2 体ほしい。
5000G 以上の腕輪が必要なため、このエリアに来る前に集めておくこと。未識別でもよい。
逆に言えば、識別に使えなくもない(勧誘できなければ「○○よけ」か)。記憶力が良ければ……
モンハウがあれば、そこのアイテムをあげて好感度を確保しておく。消費 MP を削るのが大事。
あと、余裕があればモスのレベルを上げて HP を 54 以上にしておくと今後役立つ。かも。

また、スロウビーが序盤の戦力として優秀。 5000G で勧誘できる。
プロ個体は「回復の踊り」 (HP +20) が使えて地味に便利。
こちらは 1 体で十分。また、勧誘できなくても別の仲魔戦法があるので諦めない。

プロジギタリスは「混乱の歌」を使うため、混乱耐性がない場合は環境設定から SE を 0 にしておくこと。
とくにモンハウでやられると命にかかわるので忘れないこと。
次のエリアにも歌い手がいるのでしばらく SE なしで進めることになるかも。

スラグが厄介、高耐久・高火力・修正値 -3 といいことがない。運が悪いとエリアを抜けるまでに -10 にされたりする。
ここだけメッキ付きのサブ武器運用するのも手か。
大抵の場合矢を 1 発当てれば殴りで倒せる。

ファクシーはとにかくうざい。配置によっては対処アイテムを切らざるを得ないので腹立つ。

見渡すで階段やモンハウを察知可能。昼間なら遠くにいるモスを視認することもできる。
階段は背景の森が切れている部分にある。

お金が 10000G 以上あり、かつ称号持ちの装備ではない場合、鑑銘師ナナに称号を付けてもらうのも手。ただスロウビー勧誘を含めるとお金がない可能性が高いのでそこは応相談。
うっかり敵対させないように、近くを通るときは罠がないことを祈る。
あとこの方法だとメイン防具が暗黒企業になる可能性もゼロではないのでそうなっても泣かないこと。

25-29 F

青杖使い・ジャバウォック・吸血バット・ホルスタイン・メデューサ・ポットフェアリー

青杖使いの「怒り魔法」がやばい。杖の音がしたら注意。聞こえないかもしれないが……
加えて、プロは「テレポート魔法」で仲魔と分断してくる。射線上に立たないこと。
店の商品の杖の上に陣取っていることがある。その場合商品をテレポートさせることがあり、ほぼ泥棒確定となるため注意(1敗)

メデューサは高火力の上石化破壊してくるので HP に注意。プロは混乱まで使う。
前のフロアで狐が拾えていれば勧誘もできるかもしれない(やったことはない)
ホルスタインは会心で 80 以上のダメージを与えてくる場合もある。仲魔と連携しよう。
プロの「やまとばし」で吹き飛ばされて崖落ちテレポすることも。重装印が欲しい。
ジャバウォックの混乱の歌・吸血バットの超音波を防ぐため、混乱耐性がなければ SE は 0 のままにしておく。
ポットフェアリーで杖を合成しておくと捗る(特に回復の杖など。状態異常杖は投げても使えるので少しもったいない)。

プロのジャバウォックは「むき出しのツメ」+「混乱攻撃」を持つため、通常攻撃に高確率の混乱が付与されている。
5000G 以上の腕輪で仲魔にできるため、モス勧誘で余った腕輪はここで使ってもよい。
撃たれ弱いので運用は難しいが優秀。モンハウで「混乱の歌」をかけて補助してもらうなど。

見通しはいいので、見渡すでうまく外周階段やモンハウ・民家・店を見つけていくこと。
一応このあたりからハルピュイアが出始めるらしい? (wiki のコメントより) が見たことはない。

30-34 F

ブルードラゴン・ファフニール・ハイリザード・シャウラ・カソ

暑いため自然 HP 回復が 1/6 になる。つらい。
モスに頻繁に頼ることになるため、 MP・SP 管理に注意。
また、ここまで来たら SE 設定を元に戻してよい。

ここまでに炎の盾が入手できていないと地獄。ブルードラゴンとプロハイリザードに切り札を燃やされ続ける。
RTA 的にはリセットまであるかもしれない?
ただ、結構な確率で入手できないまま進行するのでしんどい。うまく戦闘回避するしかない。

プロファフニールは「叩きつけ」で怯ませてくるので 2 回受けられる HP が必要。こまめに回復すること。
逆に仲魔にできれば 80F あたりでも通用する火力で頼もしい。眠りの杖を確保しておければここで使う。燃やされないといいね……(n敗)

部屋の端にいる赤点は常にブルードラゴンだと思って対処すること。基本的には通路で戦うべき。

ここまでに鈍足耐性がない場合、ハイリザードは超強敵となる。あげくプロは「がまん」で確定数を増やしてくる。
どうにかして耐性を確保したい。
「がまん」の効果を忘れないこと(n敗)。倒せるはずが倒せず反撃で死ぬ事故がしばしば。

カソはうざい。「吹き飛ばし魔法」で仲魔を分断してくることもあるので注意。あと「発火」は炎属性なので燃える。

ここも外周階段なので、フロアの縁を探索するようにすると早めに見つかるかも。

このあたりから?「物忘れの果実」「嫉妬の禁書」が出始めるので、漢識別に注意。
というか未識別の果実は以降食べてはならないし、本は階段以外で読んではいけない。
逆に、このエリアで変化のポーチからハルピュイアが出たことがある (1勝) のでそれに賭けてみるのも手か。

35-38 F

変化キツネ三尾・ピンクスライム・ガーディアン・ズールーア・ブルームメイド・ブラウニー

変化キツネ三尾がいるのでここで拾ったアイテムには注意。必ず投げてチェックすること。

ズールーアの槍に注意、例によって視界外から殴られると気づきにくいので HP 管理が大事。

プロブラウニーには絶対行動させないこと。催眠だけは許してはならない。
あと高額お金投げもしてくるので迂闊にダッシュし続けないこと。

罠消しの本を持っていた場合、プロのブルームメイドを勧誘して最終フロア対策とする手もある。
逆に夜間の場合は部屋で戦わないよう注意。「星に願いを」を連射される。

一応、ここは壁が掘れない。

39-42 F

紫杖使い・ケットシー・ハニースライム・カーサ・リッチ

やべーやつしかいない。絶対即降りゾーン。

紫杖使い(倍速魔法)が脅威。かといって対策しようもない。 2 回動かれるものと思って対処すべき。
青杖使い同様にプロはテレポートも使うので仲魔管理に注意。

ハニースライムがいるので低 HP で動くのは避けたい。

鈍足耐性がない場合、倍速カーサに 4 連撃を食らうことになる。防御も下がるので危険。

リッチは絶対に隣接したまま行動させないこと。ケチるつもりが全部失うことになりかねない。
通常プレイの場合はプロリッチを勧誘する手もあるらしいが、 RTA の場合 200 HP もないことが多い。

ケットシーも隣接したまま行動させないこと。ケチると死ぬ。プロは七転八起があるので低 HP のまま攻撃しないこと。
一応盗まれたときに「***を床に置いた」と表示された場合はその位置に店があるので回収できなくはない。

麦畑マップなので視界は悪いが多少見渡すこともできる。民家と店があるマップではあるが、即降りを優先したほうが良い。
例によって階段は外周にあるのでフロアの縁を探すとよい。

43-46 F

ポイズンスライム・ベノムスライム・ベニテング・デルフィネ・ブエル

毒ゾーン。ここまでに毒よけの腕輪(か称号盾の毒無効)があるとかなり楽になる。
最序盤から持ってくる……には枠が足りないので、直前に拾えたらラッキー、ぐらい。

ブエルに要注意。「偽りの祝福」を見落として散った冒険者は多い(n敗)
ただでさえ毒で HP が減りがちなので早めの回復を心がけたい。
プロは「聖なる波動」で状態異常回復と「いかずち魔法」で遠距離攻撃してくるので、射線上に立たない・状態異常に頼りすぎないこと。

47-51 F

プロテクトシザー・テンタクル・ローレライ・ダゴン・アイテムイーター・エンプーサ

水棲ゾーン。今までよりは比較的マシ。

仲魔を失っている場合、テンタクルを仲魔にする手がある。ただ Lv の関係上そこまで頼れない。
仲魔条件は「発生時点から今まで仲魔が場に出ていないこと」なので、途中でモスを出してはいけないことに注意。

ローレライの「癒しの旋律」でモンハウの敵が起こされることがあるので注意。
また、プロ個体は「夢見せ」で永眠させてくることがあるので囲まれているときは注意(1敗)
「濁酒・村一番」が拾えている場合、仲魔にすると回復要員として運用可能。モスほどではないが助かる。

プロテクトシザーは石壁スキルで遅延してくるので、そのあいだに囲まれないように。
万が一アルファだと面倒なことになるので注意。

アイテムイーターで杖を合成しておくとよいかも。

52-55 F

変化キツネ五尾・エキドナ・ヌメック・グール・ダークオウル・ダークエルフ

変化キツネがいるのでここでも投げチェックを怠らないこと。

エキドナがやばい。叩きつけでアイテムを水路にぶちこんでくるので絶対に水路の周辺で戦わないこと。純粋に火力も高い。
どうにか仲魔にできれば強いかと思われるが、 RTA だと狐識別に時間をかけられないので難しいところ。

ヌメックは論外。行動させてはいけないが矢一発と殴りで倒せるか怪しいところ。
サビ印があるならワンチャン消してもらって枠を開けるのも手か。理想論だけど……

56-59 F

オーガヘッド・クラッター・レッドファング・テンペスト・コカトリス

水晶フロアなので回復が比較的しやすい。重装印さえあれば楽。
逆に言えば重装印がない場合は吹き飛ばしやぶん投げで分断されがち。ソロで戦うべき。

仲魔がいない場合、ここでレッドファングを仲魔にする手がある。

コカトリスが純粋に強い。石化破壊が強い。
あとはテンペストの 3 連撃に気を付けるぐらい。

外周階段なので外周を観察しよう。

60-64 F

変化キツネ六尾・ゴブリンロード・しのやくいん・フレイムリリィ・ハイエルフ・パラライビー

狐に注意。
ゴブリンロードと戦うときは絶対に水路を背にしないこと。剣が水没する(3敗)
万が一水没した場合、「瞬間移動の杖」「引き寄せの杖」「吹き飛ばしの杖」「場所替えの杖」「日照の魔法書」「氷の魔法書」「○柱の杖」があれば救出可能。
○柱の杖の場合はアイテムドロップ順を考えてやらないと再度水没するので注意。

プロしのやくいんの「火事場攻撃」に注意。下手に削ると即死しかねない。
2 印があれば 1 ターンで倒せることも多いので問題ないが、ない場合注意。

ハイエルフはうざい程度で済めばいいが、鈍足の矢や脱力の矢を受けると面倒。うまく通路に誘導したい。

お金に余裕があればパラライビーを勧誘するのも手。ただしレベル上限の関係上スロウビーほど長期の活躍はできない印象。

開けたフロアなので、うまく見渡すを活用して階段を見つけたいところ。

65-67 F

フェアリー・ガオケレナ・アグニス・ダチュラ・ダークバット・エンプーサ

マナ乱れフロア。意識しておかないと緊急時に事故る。

アグニスの糸は火属性もあるので燃やされる、火炎・鈍足耐性がない場合死ぬ。
もし耐性を取れなかった場合は自分からは絶対に踏まないようにしつつ、杖や本で状態異常にして対処する。(なおダチュラ)

ガオケレナは回復特技持ちなので遅延されがち、普段は大して問題にならないが別の敵と絡んでいる場合・アルファだった場合に要注意。

ダチュラがいるのでバフ・デバフともに使いにくいことに留意。杖も使えないので搦め手なしで戦えないと厳しい。

きのこで視界が悪いが一応見渡しが効くので、水路の向こう側を見るなどで探索時間の短縮を図りたい。

68-70 F

青杖使い・ガオケレナ・アグニス・ダチュラ・ダークバット・エンプーサ

青杖使い(怒り魔法)がいるので例によって危険。

階段は魔法陣タイプで光り輝いているので、見渡すで見つけやすい。

71-75 F

イージス・ユニコーン・アルテミス・ヤシャ・グレムリン

マナ乱れフロア。意識しておかないと緊急時に事故る。(n敗)

回復役が足りない場合、アルテミスを仲魔にして補充できる。
回復役が勧誘できる最後のチャンスなのでやるならやる。
勧誘した場合、ちゃんと好感度を最高にするところまでやっておく。後々 MP 切れなどで使えなくては意味がない。(1敗)

事故の権化、ヤシャがいるので長居は避けたい。急にグレムリンが飛んでくる。低 HP での行動は避けること。
モンハウを起動してしまうとヤシャが延々飛ばしてきて死ぬので注意。

グレムリンの本読みは大抵の場合無駄行動だが、最悪(流星群・召喚・眠り)を考えると危険。
あとプロ個体の催眠だけは食らってはいけない。プロ個体は地味に水路を渡れるので注意。

ここも水路があるので見通しが効くことが多い、階段は登り階段なので比較的見つけやすい。

76-79 F

セイレーン・ドーマウス・ネモフィラ・ファンガス・サキュバスライム

眠りゾーン。そのまま永眠しかねないので注意。

ネモフィラには近寄らないこと。

ファンガスは状態異常耐性をはがしてくるので睡眠よけ・魅了よけがあっても気が抜けない。しかも自然 HP 回復が止まるので持久力が落ちる。

で、耐性をはがされたところにサキュバスライムの遠隔魅了が飛んでくるので死。
ここは一人で行動したほうがいいかも。

見通しはいいうえ外周階段なので早く階段を見つけたいところ。

80-84 F

変化キツネ八尾・リザードロード・カーバンクル・バステト・シルキー・スペクター

アイテム破壊ゾーン。握られたり燃やされたり呪われたりする。

変化キツネ八尾は厄介だが、近づくと炎が見えることがあるので比較的索敵しやすい。
食糧難・回復リソース切れの場合、最悪変化キツネ八尾に握ってもらうことで回復できる可能性がある。

鈍足耐性がないとリザードロードで死ぬ(n回目)。

ブルームメイド同様、シルキーを仲魔にできれば最終フロアの切り札として運用できる可能性がある。夜間は「星に願いを」に注意。

カーバンクルの槍に注意。例によって視界外からの攻撃は気づきにくく、うっかり死が頻発する。
生きた水晶であることにも注意。瞬間移動を振って逃げるのには使える。

とにかく早く抜けたいところ。トンネルの杖があると探索しやすいかも。
モンハウもあるので迂闊にダッシュ連打しないこと。突っ込むと死ぬ。

85-88 F

タンポポ・ケツァコアトル・ニンフ・ジン・ギルタブリル

倍速ゾーン。

タンポポに根こそぎ吸われることもしばしば。 5x5 範囲に近寄らないこと。
プロだと影縛りで逃げることもできなくなる。

ケツァコアトルは麻痺の反撃があるため、耐性がない場合迂闊に手を出さないこと。
なお、仲魔にできればかなりタフなので、偶然「ネイルセット」を持っていた場合は勧誘してみるのも手。狙うほどではない(というか余裕がない)が。

ニンフはマジでどうしようもないので低 HP で接敵しないようにする。
2 印がないと大体 2 発かかるので厳しい。
アルファニンフに遭遇した場合 (1敗) は諦める……わけにもいかない。
状態異常以外の投げ(瞬間移動の杖、瞬間移動の果実など)は通るのでそれで頑張る。爆発で吹っ飛ばす。など。

ジンが高速で巡回しているので接敵しやすい。

ギルタブリルの 3 回攻撃もだいぶ厳しい。ただ脆いので火力があれば攻撃をもらう前に倒せる。

見通しは比較的良いので、階段(光る魔法陣)を目印に最短経路で進みたい。

89-92 F

ユキジョロウ・タイタン・ドラウグル・コレクターポット・フェンリル・ペリウィンクル

凍結ゾーン。凍結されると死ぬ。ここのためだけに氷耐性が必要。

タイタンの超火力が本当に危険。会心すると 80 ダメージを超える。
もし呪われた斧(ミノタウロス・ビーストキラーィ)があれば勧誘も可能(だけど引けたためしがない)。

ドラウグルの「すてみ」も意外と大ダメージになるので計算を間違わないように。

一応民家もある。本が補充できるとありがたいところ。
あと最後の合成チャンスなので、途中で炎の盾が拾えていれば合成しておくなど。

外周階段マップなので、外を周回するように……と行きたいところだが、フロアの構造によっては敵が外周を巡回するようになる場合があるので厳しい。

93-96 F

ケルベロス・デミリッチ・ミネルバ・ダイダロス・ティアマット・アマニタ

アマニタを殴ってはいけない。絶対に殴ってはいけない。
一応カカオ菓子があれば勧誘可能。

ケルベロスは呪いブレスに当たらないように。

デミリッチは当然行動させてはならない。お供の超音波も地味に危険。
混乱耐性がなければ忘れずに SE を 0 にしておく。

ミネルバで視界が狭まっているときが本当に危ない。行動させてはいけないやつがうようよいる可能性がある。
何も対策がない場合は瀕死勧誘して次のエリアの避雷針(?)になってもらう戦法もあるかも。

ダイダロスは突然「まかいこうせん」を撃ってくるので HP に余裕を持つこと。罠にも注意。
あとこれで地形が変わるので惑わされないように。

ティアマットのプロ個体は例によって「叩きつけ」で実質 2 回行動してくるので注意。

もし「星に願いを」部隊がいるなら、このあたりで時間調節して夜間になるようにしておく。

97-99 F

ダークドラゴン・ミアズマ・フレスベルグ・インヘーラー・マスティマ・ダーゴイル

即降り。できればの話だが。
深層で運よく明かりの本を見つけた場合はここまで取っておけると吉。

無限に燃やされるので火炎耐性がないと詰む。(1敗)
ミアズマの糸に触れると体調不良→耐性貫通鈍足してくるので注意。近づいてはいけない。
基本的に戦っている場合ではないので、飛びつきの杖やテレポート手段を駆使して階段を探す。
状態異常で足止めしようとしてもマスティマが解除してくる。最悪。

階段は魔法陣なので光っている。頑張って見つけよう。

最悪の場合、火炎放射が飛んできた方向に仲魔を召喚・待機させて盾にする方法もある。
プロダークドラゴンの火炎は 53 ダメージなので、ここでモスの HP を 54 以上にしておくと 2 回受けさせることができる。

最終フロアは確定大部屋モンハウ。
飛びつきの杖か場所替えの杖が必須。射線上にあることを祈る。

石像はあったりなかったりするようだ (固定 3 個配置かも?ただ魔法封印があるとは限らない)。場所替えの石像 x3 とかいうパターンもある。
殲滅を狙う場合は魔法封印の石像がないことをメニューから確認すること。

アイテム運用など

値段表は以下を参照
https://docs.google.com/spreadsheets/d/17Mu99r0xRcwZHaKWqbop7uAUh8sP3wwWILFFQHZnDeg/edit?gid=0

武器

会心・金床(バール)・2回 が最優先。
「疾風剣ハルピュイア」が拾えると打開がかなり近づく。
「ミノタウロスの小斧」と「バールのようなもの」は比較的低層でも拾えるので合成しておく。
種族特効印を入れすぎてこれらが入らない、といったことがないようにしたい。

称号の雑な tier は、

  • 神
    • 杖絞りの・すばらしい・本当に凄い・波動の・通路好きの・魔術師の・孤高の
  • 有効
    • 小食の・ダメ押し・大きな・巨大な・冒険者の・良い・上質な・すごい・達人の・火事場の・聖なる(※バステトには無効)・杖宿りの・転ばぬ先の・本宿りの・旅人の・水を得た
  • ほぼ使わない(使えない)
    • 友愛の・霧を斬る・守銭奴の・やせ我慢の・強敵狩りの・結晶砥石の・穿孔の・かわいい・マナ喰らい・淀みの・傭兵の・殲滅の・英雄の・熱を帯びた・凍てついた
  • デメリットあり
    • 気合の(SP-1)・運命共同の(デバフ共有)・一点集中の(盾なし、場合によっては考慮)・金溶の・大金溶の(お金--)・勝気な(自然 MP 回復0)・博打の(x0.5)・吸血の(自然 HP 回復0・そもそもない)

「旅人の」はアイテムが保持しにくい関係上他のダンジョンより有効に使いやすい。
あとは基本値+・状態異常攻撃がついているとなおよい。強化限界+は無意味。
「熱を帯びた」「凍てついた」は序盤の該当エリア (13F~、30F~) ではかなり有効なので臨機応変に。

盾

炎が最優先、次点で氷・重装・鈍足。
炎がないと最終フロアで焼かれて泣くことになる。
が、そんなに落ちているわけでもない(打開まで拾えないことさえある)ので祈るしかない。

  • 神
    • 火事場の・活力の・七転八起の・枕靴下の・巨大な・すばらしい・本当に凄い・金溶の(お金--)・魔術師の・孤高の・通路好きの・臆病者の
  • 有効
    • 小食の・旅人の・万全の・危機管理・お祝いの・大きな・冒険者の・良い・上質な・すごい・二人三脚な・水を得た・爆森人の・錯乱の・本宿りの・冷静な・斥候の
  • ほぼ使わない(使えない)
    • ダメ押しの・やせ我慢の・凍てついた・沙汰金の・友愛の・傭兵の・大金溶の・熱を帯びた・快眠枕の・快感の・かわいい・聖人の・一点集中の・殲滅の・親愛の・英雄の・守銭奴の
  • 特定タイミングで有効(サブ盾用)
    • 火炎導師の・回復導師の・氷結導師の・風塵導師の・雷神導師の
  • デメリットあり
    • 暗黒企業な(SPダメージ)・気合の(SP-1)・運命共同の(デバフ共有)・正面装甲の(正面以外ダメージ+)・不動の(移動時ダメージ+)・無我の(HP回復半減、そもそもない)・博打の(x2.0)・爆弾魔(投擲爆発)

○○導師系は未識別の本を読むと識別されるので便利。特に回復導師。ただメイン盾だと事故るのでサブ盾としてか。
正面装甲はうまく運用できれば強いかも。囲まれると厳しい。

腕輪

(筆者は)プレゼント用と割り切っているが、識別できた場合は以下のビルドがよさげ。

  • ちから x2
  • ○○よけ x2 (混乱よけ・鈍足よけ・毒よけ・睡眠よけ)
  • 会心
  • 持久

価格帯は、

  • 2000: ちから確定
  • 3000: ○○よけ
  • 5000: いろいろ(使えるものもある)
  • 10000: いろいろ(投げ系など、使えないものが多い)
  • 15000: 娘守り確定

5000G 以上の腕輪は勧誘に使えるので売却せずにとっておく。

果実

鈍足無効や状態異常攻撃を合成できると便利。
あとは瞬間移動の果実・大きい回復の実・復活の実など。
復活の実は 10F で床落ちしているのを確認。

価格帯は、

  • 10: しなびた・腐った
  • 100: 小さい回復確定
  • 200: ちから・瞬間移動・魔法など
  • 300: 倍速確定
  • 400: 大きい回復確定
  • 500: 状態異常系・ 物忘れ
  • 1000: 特大回復・命・封印・予防
  • 1500: 復活 ・魔力増大など

30F を超えたあたりから物忘れが出現しはじめる(体感)ので、それ以降は未識別の実は食べないこと。
逆に言えば 30F 以下で見つけた 500/250 の果実は状態異常系なので合成素材としても有用。

杖

主力の対処アイテム。燃やされないように祈る。

トンネルの杖

掘れるほか、序盤のダメージソースにも。終盤の一刻も早く階段に移動したいときや開幕モンハウで壁に隣接していた時などに。

癒しの杖

80F~ のバステトに呪われたとき用。ほかにも偶然呪われていたアイテムの解呪にも。
一本あると安心だが、いつも使うわけではないのでポーチ内保管でも可?

回復の杖

緊急回復用。でも水晶がなかったりする。
投げ当てることはあまりないのでポットフェアリー合成で優先的にまとめておくとよい。

毒の杖

序盤のアルファ狩りに。毒ダメージは最大 HP の 2%/3%/5% (20T持続) なので、 3 回振って 20T 待てば確実に HP 1 にできる。

封印の杖

チャージスキルのチャージ中に封印すると追加で混乱する。
フクロウ系やユキンコ系のフィールド効果も消せるため便利。ただし無駄行動しなくなるので HP に注意。

引き寄せの杖

一応池ポチャしたときの救出・浮島内部のアイテム回収には使える。

瞬間移動の杖

相手を飛ばしてもいいし、自分を飛ばしてもいい。池ポチャ救出にも使える。

飛びつきの杖

言わずと知れた最強の杖、 99F でこれがないと命にかかわる。
高速移動だけでなく谷や水路に飛びついてテレポなど、応用も幅広い。

場所替えの杖

軸が合っていれば飛びつき同様に使えるほか、池ポチャ救出(やそれを逆手に取った自身のテレポート)もできる。

眠りの杖

完封できるのでつよい。ファフニールの勧誘用にも。

○柱の杖

アイテムが存在できなくなって近くのマスに落ちるため、池ポチャ救出にも使える。
氷柱は足止め・水晶代わりの利用・氷砕き、火柱はモンハウ入口に焚く、など。

価格帯は、

  • 100: トンネル・一時しのぎ・石化・癒し・隠れ身
  • 120: 回復・毒・雷電
  • 140: 混乱・封印
  • 150: 移動系・体力交換・物知り
  • 160: 状態異常系
  • 180: 眠り確定
  • 200: いじわる・そんたく・衰弱
  • 250: 柱系・空振り・誘い

なお、いちしの・隠れ身・物知り・体力交換・衰弱あたりは見た覚えがない。

本

5000 の本に要注意。読んでいいことはあまりないので金策・プレゼント用に。

殲滅狙いなら石像止めを 99F に持っていくとよいが、そんな余裕あるか?

価格帯は、

  • 回数制
    • 100: 識別確定
    • 500: 石像止め確定
    • 600: 炎氷風雷
    • 750: 油揚げ化確定
    • 1000: 回復・大爆発・予防・招集
  • 一回限り
    • 500: 召喚・罠見え
    • 1000: 解呪・混乱・呪い・鈍足・倍速・封印・麻痺・眠り・罠消し・罠増し
      • どれを?なら呪い確定
    • 1200: 明かり・迷い
    • 1500: メッキ・武器強化・防具強化
    • 2000: アイテム寄せ・鑑定(ない?)・空振り・集団転移・小流星・千里眼・日照・目つぶし
    • 3000: 幻揚げ確定
    • 5000: ポーチ拡張(ない?)・ポーチ強化・マナ乱れ・ 嫉妬 ・聖域・大雨・長期休暇・定時帰宅(?)・流星群(?)

ポーチ

早い段階で使う系のポーチを識別できるとよい。特に回復と爆発。

回復はもちろんのこと、爆発は敵を消すために重要。転倒したら泣く。
アイテムドロップ順を計算して投げるのも時には重要。

価格帯は、

  • 250: 転送(ない?)・変化
  • 500: 保存(激レア)・果実拡散・識別
    • 識別は[2~4ï¼½
  • 600: 回復・爆発・召喚
  • 1000: お狐様・誰か
  • 2000: 合成・弱化
    • ï¼»2]なら弱化確定、[5]なら合成確定。

矢

ダメージ目的のほか、 12 マス飛ぶので飛びつき・場所替えの起点づくりにも。
銀の矢はモンハウの敵を起こせる。

パン

絶対に無駄遣いしないこと、あとで飢え死にする。
適当な(変化ではない)ポーチに入れておくとよい。
巨大なパンは SP +110 なことに注意。回復量が大きなパン x2 より減る。

ギフト他

基本的には投げて使う。
濁酒 (混乱)・リンス (空振り) が強力。カカオ菓子は MP 回復やアマニタ勧誘に。
油揚げも貴重な HP・SP 回復手段なので持っておくとよい。最悪ニギライズして作る。

お金は序盤の対アルファ決戦兵器。拾って財布に入れないように。
鈍足の杖とかが拾えた時点で財布に入れてよい。

C# で高速な Dictionary を自作する

はじめに

プログラミングするうえで、 Dictionary<TKey, TValue> はもはや必須と言ってもいい要素でしょう。
しかしこの Dictionary 、実際どういう仕組みで動いているのかわからない、うっすらハッシュを使っていることはわかるけど具体的には……?、という方もいらっしゃるかと思います。というか私がそうでした。
なので、本稿では Dictionary がどうやって動作しているのか、自分で高速な Dictionary を実装するにはどうしたらよいかについて説明したいと思います。

どうしてわざわざ Dictionary を自作するのかというと、内部で ArrayPool<T> を利用する Dictionary を実装してアロケーションを減らしたいからです。こと Unity ではアロケーションに敏感なことが多く、減らせるものは減らしたいですからね。Dictionary は使いたいシーンが多いので、自作することにしました。それで、どうせ自作するならパフォーマンスのいいアルゴリズムを使いたいので、いろいろと調べてみて今に至ります。

Dictionary の実装方式について

Dictionary の実装方式はけっこうたくさんありますが、ざっくり分けると「ハッシュテーブルを用いる形式」と「木を用いる形式」があります。
本稿では主に「ハッシュテーブルを用いる形式」のなかから、 6 つの手法について説明します。

ハッシュテーブルを用いる形式は、「ハッシュコードからテーブルを引く」ところまでは同じですが、ハッシュコードが衝突した際の扱い方やデータの持ち方がそれぞれ異なります。

Separate Chaining

この方式では、ハッシュコードが衝突した際に LinkedList のように要素をつなげていくことで解決を行います。
なお、 C# の Dictionary はこの方式で実装されています。

データの持ち方はこんな感じです。

private struct Entry  
{  
    public TKey Key;  
    public TValue Value;  
    // 次の要素のインデックス なければ -1  
    public int Next;  
}  
  
// 当該ハッシュの先頭要素のインデックス なければ -1  
private int[] m_Buckets;  
private Entry[] m_Entries;  
private int m_Count;  
// 削除済み要素へのインデックス なければ -1  
private int m_FreeIndex;  

例えば、以下のようなデータ群があった場合に、

block-beta  
columns 7  
  
space Alice space Barbara space Charlotte space  
space:7  
space AliceHash["123"] space BarbaraHash["456"] space CharlotteHash["987"] space  
space:7  
space AliceIndex["3"] space BarbaraIndex["0"] space  CharlotteIndex["3"] space  
  
Alice -- "GetHashCode()" --> AliceHash  
Barbara -- "GetHashCode()" --> BarbaraHash  
Charlotte -- "GetHashCode()" --> CharlotteHash  
  
AliceHash -- "% 4" --> AliceIndex  
BarbaraHash -- "% 4" --> BarbaraIndex  
CharlotteHash -- "% 4" --> CharlotteIndex  
  

Alice, Barbara, Charlotte の順に格納したとすると、こういう感じになります。

block-beta  
  
columns 4  
  
block: inner2  
columns 1  
index2["[0]"]  
key2["Key: Barbara"]  
value2["Value: Barbara"]  
next2["Next: [-1]"]  
end  
  
block: inner3  
columns 1  
index3["[1]"]  
key3["Key: Charlotte"]  
value3["Value: Charlotte"]  
next3["Next: [-1]"]  
end  
  
block: inner4  
columns 1  
index4["[2]"]  
key4["Key: -"]  
value4["Value: -"]  
next4["Next: [-1]"]  
end  
  
block:inner1  
columns 1  
index1["[3]"]  
key1["Key: Alice"]  
value1["Value: Alice"]  
next1["Next: [1]"]  
end  
  
next1 --> index3  

Alice と Barbara はそのまま指定のインデックスに格納できますが、 Charlotte はハッシュのインデックスが Alice とかぶっています。
そのため、適当な空き地を見つけてそこに Charlotte を格納して、先に入っていた Alice の Next に Charlotte のインデックスを設定することで、後から辿れるようにします。
リンクという観点からすると、下図のほうが分かりやすいかもですね。

block-beta  
  
columns 4  
  
block: inner2  
columns 1  
index2["[0]"]  
key2["Key: Barbara"]  
value2["Value: Barbara"]  
next2["Next: [-1]"]  
end  
  
space  
  
block: inner4  
columns 1  
index4["[2]"]  
key4["Key: -"]  
value4["Value: -"]  
next4["Next: [-1]"]  
end  
  
block:inner1  
columns 1  
index1["[3]"]  
key1["Key: Alice"]  
value1["Value: Alice"]  
next1["Next: [1]"]  
end  
  
space:3  
  
block: inner3  
columns 1  
index3["[1]"]  
key3["Key: Charlotte"]  
value3["Value: Charlotte"]  
next3["Next: [-1]"]  
end  
  
next1 --> index3  

探索 (Get)

まずは、どうやってキーからエントリを得ているかを見てみましょう。

private static int GetBucketIndex(int hashCode, int bucketLength)  
{  
    return hashCode % bucketLength;  
}  
  
private int GetEntryIndex(TKey key)  
{  
    if (key == null)  
        throw new ArgumentNullException(nameof(key));  
  
    // ハッシュ値計算  
    int hashCode = key.GetHashCode();  
    int bucketIndex = GetBucketIndex(hashCode, m_Buckets.Length);  
  
    // バケツのインデックスを起点としてリストを辿る  
    int entryIndex;  
    int safetyCount;  
    for (entryIndex = m_Buckets[bucketIndex], safetyCount = 0;  
        (uint)entryIndex < (uint)m_Entries.Length && safetyCount <= m_Entries.Length;  
        entryIndex = m_Entries[entryIndex].Next, safetyCount++)  
    {  
        if (EqualityComparer<TKey>.Default.Equals(m_Entries[entryIndex].Key, key))  
        {  
            return entryIndex;  
        }  
    }  
  
    if (safetyCount > m_Entries.Length)  
        throw new InvalidOperationException("detect infinite loop");  
  
    return -1;  
}  

まずは key.GetHashCode() でハッシュ値を得て、それを m_Entries.Length で剰余をとることで [0, m_Entries.Length) の範囲に変換します。

細かい話ですが、ここで m_Entries.Length が 2 冪であることが分かっているなら、 hashCode & (m_Entries.Length - 1) とすることで高速化できます。
ArrayPool で確保したバッファは必ず 2 冪の長さになるため (参考) 、この手法が適用できます。
なお、 2 冪で剰余をとるのではなく、それに近い素数で剰余をとることでハッシュ値の分散をいい感じにでき、衝突を減らせるそうです (特にハッシュ値の品質が信頼できない場合) 。
System.Collections.Generic.Dictionary では、コンストラクタで指定した capacity より一回り大きい素数を実際の Buckets.Length としていました。
本稿では主にハッシュ値を信用する (?) ことにして、 2 冪でやります。

続く for 文では複雑なことをしているように見えますが、要するに、

  • entryIndex = m_Buckets[bucketIndex] が指すエントリ (m_Entries[entryIndex]) を見てみる
  • そのエントリのキーが引数の key と一致するなら、そのインデックスを返す
  • 一致しないなら、 m_Entries[entryIndex].Next から次のエントリを得て、やりなおす

を繰り返しているだけです。

(uint)entryIndex < (uint)m_Entries.Length のイディオムは、 0 <= entryIndex && entryIndex < m_Entries.Length と等価です。負値を uint にキャストすると必ず 2^{31} 以上になることに注目してみてください。一度の比較で範囲チェックができます。

下のほうにある無限ループ対策は、列挙中に外部から値が書き替えられた場合のためのものです。
通常はこの if 文は通過しません。

追加 (Add)

次に、どのようにして値を追加していくかについて考えてみましょう。

// 追加に成功したら true を返す  
// overwrite == true なら既存エントリの上書きを許す  
private bool TryAdd(TKey key, TValue value, bool overwrite)  
{  
    if (key == null)  
        throw new ArgumentNullException(nameof(key));  
  
    // 既存かチェック  
    int entryIndex = GetEntryIndex(key);  
    if (entryIndex != -1)  
    {  
        // 既存かつ上書き可能 (overwrite == true) なら上書き  
        if (overwrite)  
        {  
            m_Entries[entryIndex].Value = value;  
            return true;  
        }  
        else  
        {  
            return false;  
        }  
    }  
  
    int bucketIndex = GetBucketIndex(key.GetHashCode(), m_Buckets.Length);  
  
    // 個数が溢れる場合はリサイズ  
    if (m_Count == m_Entries.Length)  
    {  
        Resize(m_Count << 1);  
        bucketIndex = GetBucketIndex(key.GetHashCode(), m_Buckets.Length);  
    }  
  
    // 書き込み先を選択  
    int newEntryIndex;  
    if (m_FreeIndex == -1)  
    {  
        // 削除済み領域がなければ末尾に  
        newEntryIndex = m_Count;  
    }  
    else  
    {  
        // 削除済み領域があればそこに  
        newEntryIndex = m_FreeIndex;  
        m_FreeIndex = m_Entries[m_FreeIndex - 1].Next;  
        m_Nexts[newIndex] = -1;  
    }  
  
    // Buckets/Entries とリンクをつなぐ  
    if (m_Buckets[bucketIndex] == -1)  
    {  
        m_Buckets[bucketIndex] = newEntryIndex;  
    }  
    else  
    {  
        int safetyCount;  
        for (entryIndex = m_Buckets[bucketIndex] - 1, safetyCount = 0;  
            (uint)entryIndex < (uint)m_Entries.Length && safetyCount < m_Entries.Length;  
            entryIndex = m_Entries[entryIndex].Next, safetyCount++)  
        {  
            if (m_Entries[entryIndex].Next == -1)  
            {  
                m_Entries[entryIndex].Next = newEntryIndex;  
                break;  
            }  
        }  
    }  
  
    // エントリを設定  
    m_Entries[newEntryIndex].Key = key;  
    m_Entries[newEntryIndex].Value = value;  
    m_Entries[newEntryIndex].Next = -1;  
    m_Count++;  
    return true;  
}  

ちょっと長いですが、やっていることはそう難しくはありません。
まずは key が既存かチェックして、既存かつ上書き可能なら上書きして終了します。

次に、ハッシュテーブルが完全に埋まっており溢れそうなら、ハッシュテーブルを拡張します。これについては後述します。

書き込み先の選択では、削除済み要素の先頭を指す m_FreeIndex が何も指していなければエントリ末尾の次 (m_Count) に、なにかを指していれば m_FreeIndex の位置に書き込みを行うようにします。詳しくは削除の項で述べます。

次に、 m_Buckets もしくは m_Entries とリンクをつなぎます。
まずは m_Buckets[bucketIndex] が何も指していない (-1) 場合、つまり同一ハッシュの先客がいなかった場合は、そこに新規エントリへのインデックスを指します。
何かを指していた場合、つまり同一ハッシュの先客がいた場合は、そのエントリの Next を辿って、末尾に新規エントリへのインデックスを追加します。

最後に、エントリを設定し、個数を増やして完了です。

ハッシュテーブルの拡張 (Resize)

エントリが埋まってしまった場合は、拡張を行う必要があります。

private void Resize(int size)  
{  
    // 指定したサイズ (ここでは元のサイズの 2 倍) のバッファを取得  
    var newBuckets = ArrayPool<int>.Shared.Rent(size);  
    var newEntries = ArrayPool<Entry>.Shared.Rent(size);  
    newBuckets.AsSpan().Clear();  
    newEntries.AsSpan().Clear();  
  
    var oldBuckets = m_Buckets;  
    var oldEntries = m_Entries;  
    m_Buckets = newBuckets;  
    m_Entries = newEntries;  
    m_Count = 0;  
    m_FreeIndex = -1;  
  
    // 旧エントリ群の追加  
    for (int i = 0; i < oldEntries.Length; i++)  
    {  
        TryAdd(oldEntries[i].Key, oldEntries[i].Value, false);  
    }  
  
    // 旧バッファの削除  
    ArrayPool<int>.Shared.Return(oldBuckets);  
    ArrayPool<Entry>.Shared.Return(oldEntries, true);  
}  

コメントの通り、新しく 2 倍のサイズのバッファを確保して旧エントリを登録、そして新旧のバッファを入れ替えるだけです。

ここで注意してほしいのは、そのままエントリをコピーしているわけではないということです。
m_Buckets や m_Entries のサイズが変わったことに伴い、ハッシュ値とバケツインデックスの対応 (GetBucketIndex() が返す値) も変化しているためです。
実質全エントリを追加しなおしになっているため、それなりに重い処理になります。

なお、ここでは横着して TryAdd() を使いまわしていますが、「そのエントリが既存かどうか」のチェックは不要になるため、そこを省略する実装にすれば多少高速化できます。

削除 (Delete)

// 削除に成功したら true を返す  
private bool TryRemove(TKey key)  
{  
    int bucketIndex = GetBucketIndex(key.GetHashCode(), m_Buckets.Length);  
    int entryIndex = GetEntryIndex(key);  
  
    // そもそも存在していなければ false を返して終了  
    if (entryIndex < 0)  
    {  
        return false;  
    }  
  
    // バケツが直接指しているエントリだった場合  
    if (m_Buckets[bucketIndex] == entryIndex)  
    {  
        // 子エントリの Next を設定して自分をリンクから消す  
        m_Buckets[bucketIndex] = m_Entries[entryIndex].Next;  
    }  
    else  
    {  
        // そうでなければ、自分を探してリンクから消す  
        int prev = m_Buckets[bucketIndex];  
        int next = m_Entries[prev].Next;  
        int safetyCount;  
        for (safetyCount = 0; safetyCount < m_Entries.Length; safetyCount++)  
        {  
            if (next == entryIndex)  
            {  
                m_Entries[prev].Next = m_Entries[next].Next;  
                break;  
            }  
            (prev, next) = (next, m_Entries[next].Next);  
        }  
  
        if (safetyCount >= m_Entries.Length)  
            throw new InvalidOperationException("detect infinite loop");  
    }  
  
    // エントリを削除  
    m_Entries[entryIndex].Key = default!;  
    m_Entries[entryIndex].Value = default!;  
    m_Entries[entryIndex].Next = m_FreeIndex;  
    m_FreeIndex = entryIndex;  
    m_Count--;  
    return true;  
}  

コメントの通り、自身をリンクから削除する (前の要素と後ろの要素をつなげる) ことで削除を行います。
LinkedList での削除をイメージするとわかりやすいでしょう。

m_FreeIndex は、削除された要素群の先頭を指すインデックスです。削除された要素も LinkedList のようにリストになっており、 m_Entries[].Next が次の削除された要素を参照しています。ここでやっていることは、要するに削除済みリストの先頭にこのエントリを追加している、ということになります。

ちなみに、エントリの実体 (m_Entries[].Key, .Value) を削除するのは必須ではありません。
別に default! を代入しなくても参照されないので問題ないのですが、 GC に拾ってもらえることを祈って明示的に消しています。

前述したように、ここでの無限ループ対策も列挙中に値が変更されたときに無限ループにならないようにしているだけです。基本的には発生しません。

Open Addressing

次に紹介する方式は、データの持ち方とハッシュ衝突時の解決法が異なります。

データの持ち方はこんな感じです。

private struct Entry  
{  
    public TKey Key;  
    public TValue Value;  
    public UsedFlags Used;  
}  
private enum UsedFlags : byte  
{  
    Empty = 0,  
    Used = 1,  
}  
private Entry[] m_Entries;  
private int m_Count;  

Separate Chaining と比べるとシンプルに見えますね。

block-beta  
columns 4  
  
block  
    columns 1  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    used1["Used"]  
end  
  
block  
    columns 1  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    used2["Used"]  
end  
  
block  
    columns 1  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    used3["Used"]  
end  
  
block  
    columns 1  
    key4["Key: -"]  
    value4["Value: -"]  
    used4["Empty"]  
end  

ここで、 Barbara とハッシュ後のインデックスが衝突するキー Diona を挿入したいとしましょう。
当然 Barbara が使用済みなのでそこには挿入できません。なので次のエントリを見ます。
次も Charlotte が使用済みなので、次を見ます。その次は空いていましたね。
なのでそこに挿入します。

block-beta  
columns 4  
  
space  
block  
    columns 1  
    keyx["Key: Diona"]  
    valuex["Value: Diona"]  
end  
space  
space  
  
block  
    columns 1  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    used1["Used"]  
end  
  
block  
    columns 1  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    used2["Used"]  
end  
  
block  
    columns 1  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    used3["Used"]  
end  
  
block  
    columns 1  
    key4["Key: Diona"]  
    value4["Value: Diona"]  
    used4["Used"]  
end  
  
keyx --> key2  
key2 --> key3  
key3 --> key4  

という感じです。

で、これを探索するときは、最初は Barbara から探索を始めます。
もちろんこれは Diona ではないので次へ。次も Charlotte なのでその次へ。次は Diona でキーが一致するので、そこの要素を返す。といった感じです。

今回は登録済みの場合に次の要素を見ていきましたが (Linear Probing) 、これを n^{2} ごと増やしていく (つまり +1, +4, +9, +16, ...) 方式 (Quadratic Probing) 、もう一回ハッシュ値を求める (つまり hash() でだめなら hash(hash()) を調べる) 方式 (Double Hashing) もあります。
似ているようで、それぞれ結構特性に違いがあります。 Linear Probing では近所にかたまるのでキャッシュヒットしやすい一方、塊ができてしまうので衝突が発生しやすく、何個も探索する必要が出て時間がかかりがちです。対して Quadratic Probing では距離が空くので衝突が発生しにくく探索は速めですが、キャッシュヒット率は下がるでしょう。

今回は、簡単のため Linear Probing について説明します。

探索 (Get)

private int GetEntryIndex(TKey key)  
{  
    // ハッシュ値からインデックスを計算  
    int hashCode = key.GetHashCode();  
  
    int bucketIndex = GetBucketIndex(hashCode, m_Entries.Length);  
    int mask = m_Entries.Length - 1;  
  
    // 順次探索、見つかったらそれを返す  
    for (int offset = 0; offset < m_Entries.Length; offset++)  
    {  
        int index = (bucketIndex + offset) & mask;  
        if (m_Entries[index].Used == UsedFlags.Used &&  
            EqualityComparer<TKey>.Default.Equals(m_Entries[index].Key, key))  
        {  
            return index;  
        }  
  
        // 途中で空きスロットが見つかったら終了 (連続しているはずなので)  
        if (m_Entries[index].Used == UsedFlags.Empty)  
            break;  
    }  
  
    return -1;  
}  

GetBucketIndex() は Separate Chaining と同じ hashCode % m_Entries.Length です。

まず hashCode から求めた bucketIndex を探索し、次に bucketIndex + 1 を、次に bucketIndex + 2 、…… と見ていきます。

途中で空きスロットが見つかった場合は探索を終了します。理想的な位置から実際の位置に至るまでに空きはなく、密に詰まっているという制約を設けているためです。

追加 (Add)

private bool TryAdd(TKey key, TValue value, bool overwrite)  
{  
    // ハッシュ値やインデックスを求める  
    int hashCode = key.GetHashCode();  
    int bucketIndex = GetBucketIndex(hashCode, m_Entries.Length);  
    int entryIndex = GetEntryIndex(key);  
  
    // 既存だった場合、上書きして終了  
    if (entryIndex != -1)  
    {  
        if (overwrite)  
        {  
            m_Entries[entryIndex].Value = value;  
            return true;  
        }  
        else  
        {  
            return false;  
        }  
    }  
  
    // テーブルの 50% 以上が埋まっていたら拡張  
    if (m_Count >= m_Entries.Length * 0.5)  
    {  
        Resize(m_Entries.Length << 1);  
        bucketIndex = GetBucketIndex(hashCode, m_Entries.Length);  
    }  
  
    // 順次探索、空きを見つけたらそこに設定して終了  
    for (int offset = 0; offset < m_Entries.Length; offset++)  
    {  
        int index = (bucketIndex + offset) & (m_Entries.Length - 1);  
        if (m_Entries[index].Used != UsedFlags.Used)  
        {  
            m_Entries[index].Key = key;  
            m_Entries[index].Value = value;  
            m_Entries[index].Used = UsedFlags.Used;  
            m_Count++;  
            return true;  
        }  
    }  
  
    throw new InvalidOperationException("never reach here");  
}  

コメントの通りで、難しいことはやっていませんね。
Separate Chaining と違って、ここではテーブルの 50% 以上が埋まったら拡大するようにしています。なぜ 100% 活用しないのかというと、 Open Addressing ではテーブルが埋まってきた際の性能劣化が著しいのです。
極端な例で言えば、 1 つだけ空きがある状態で一番距離のある地点から探索を始めた場合、テーブル全体を探査する必要があります。

(上図は Wikipedia より引用)
縦軸はキャッシュミスの頻度 (上に行くほど遅い) 、横軸は「全体の何 % 埋まったら拡大するか (Load Factor)」です。 Separate Chaining は高い Load Factor でも安定していますが (安定して遅いともいう) 、 Linear Probing は 80% を超えたあたりから急激に性能が劣化します。

そのため、劣化が大きくなる前、ここでは 50% 以上になったら拡張するようにしている、というわけです。
もちろんほかの値にすることも可能で、空間と時間のトレードオフのなかでどちらを優先するかによって適宜調節できます。

ハッシュテーブルの拡張 (Resize)

private void Resize(int size)  
{  
    // 新バッファの確保  
    var newEntries = ArrayPool<Entry>.Shared.Rent(size);  
    newEntries.AsSpan().Clear();  
  
    // 使用中領域の転送  
    for (int i = 0; i < m_Entries.Length; i++)  
    {  
        if (m_Entries[i].Used == UsedFlags.Used)  
        {  
            int bucketIndex = GetBucketIndex(m_Entries[i].Key.GetHashCode(), newEntries.Length);  
            for (int offset = 0; offset < newEntries.Length; offset++)  
            {  
                int entryIndex = (bucketIndex + offset) & (newEntries.Length - 1);  
                if (newEntries[entryIndex].Used != UsedFlags.Used)  
                {  
                    newEntries[entryIndex] = m_Entries[i];  
                    break;  
                }  
            }  
        }  
    }  
  
    // 旧バッファの解放  
    ArrayPool<Entry>.Shared.Return(m_Entries, true);  
    m_Entries = newEntries;  
}  

新しいバッファを確保した後は、既存の使用中エントリを検索して、ハッシュ値からインデックスの再計算を行い、新バッファへ格納します。
ここでも単純にコピーしているわけではないことに注意が必要です。ハッシュ値は同じですが、そこから計算するインデックス (GetBucketIndex() の返り値) は変化しています。

削除 (Delete)

private bool TryRemove(TKey key)  
{  
    // 探索して見つからなければ終了  
    int entryIndex = GetEntryIndex(key);  
  
    if (entryIndex == -1)  
    {  
        return false;  
    }  
  
  
    // 要素を削除  
    m_Entries[entryIndex].Key = default!;  
    m_Entries[entryIndex].Value = default!;  
    m_Entries[entryIndex].Used = UsedFlags.Empty;  
  
    // 削除した要素を詰める  
    int currentIndex = entryIndex;  
    int nextIndex = entryIndex;  
    for (int offset = 1; offset < m_Entries.Length; offset++)  
    {  
        nextIndex = (nextIndex + 1) & (m_Entries.Length - 1);  
  
        if (m_Entries[nextIndex].Used != UsedFlags.Used)  
        {  
            break;  
        }  
  
        // 移動中要素の本来あるべき位置  
        var nextOrigin = GetBucketIndex(m_Entries[nextIndex].Key.GetHashCode(), m_Entries.Length);  
        // 移動後に本来あるべき位置からの距離が大きくなってしまう場合はスキップ  
        if (currentIndex <= nextIndex)  
        {  
            if (currentIndex < nextOrigin && nextOrigin <= nextIndex)  
            {  
                continue;  
            }  
        }  
        else  
        {  
            if (nextOrigin <= nextIndex && currentIndex < nextOrigin)  
            {  
                continue;  
            }  
        }  
  
        m_Entries[currentIndex] = m_Entries[nextIndex];  
        m_Entries[nextIndex].Used = UsedFlags.Empty;  
        currentIndex = nextIndex;  
    }  
  
    m_Count--;  
    return true;  
}  

要素を削除する部分は自明ですね。
削除したら、後続の要素を手前に移動します。このとき、ただ移動するだけだと不都合があるので一工夫が必要です。
具体例を挙げて説明してみましょう。

block-beta  
columns 4  
  
block  
    columns 1  
    id1["[0]"]  
    key1["Key: Alice"]  
    index1["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    id2["[1]"]  
    key2["Key: Barbara"]  
    index2["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    id3["[2]"]  
    key3["Key: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block  
    columns 1  
    id4["[3]"]  
    key4["Key: Diona"]  
    index4["IdealIndex: 0"]  
end  

IdealIndex は本来その要素が位置するべきインデックスです。上の擬似コードで言う nextOrigin みたいなものですね。
ここで Alice を削除したとしましょう。

block-beta  
columns 4  
  
block  
    columns 1  
    id1["[0]"]  
    key1["Key: -"]  
    index1["IdealIndex: -"]  
end  
  
block  
    columns 1  
    id2["[1]"]  
    key2["Key: Barbara"]  
    index2["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    id3["[2]"]  
    key3["Key: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block  
    columns 1  
    id4["[3]"]  
    key4["Key: Diona"]  
    index4["IdealIndex: 0"]  
end  

このままだと Barbara はアクセス不能になってしまいますので、前に詰めます。

block-beta  
columns 4  
  
block  
    columns 1  
    id1["[0]"]  
    key1["Key: Barbara"]  
    index1["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    id2["[1]"]  
    key2["Key: -"]  
    index2["IdealIndex: -"]  
end  
  
block  
    columns 1  
    id3["[2]"]  
    key3["Key: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block  
    columns 1  
    id4["[3]"]  
    key4["Key: Diona"]  
    index4["IdealIndex: 0"]  
end  

次は Charlotte ……と行きたいところですが、 Charlotte はすでに理想とする位置に存在しており、 [1] に移動させてしまうと、現在よりも理想の位置から離れてしまいます。したがって、 Charlotte は飛ばして Diona を見に行きます。
Diona は移動したほうがよさそうなので、移動させます。

block-beta  
columns 4  
  
block  
    columns 1  
    id1["[0]"]  
    key1["Key: Barbara"]  
    index1["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    id2["[1]"]  
    key2["Key: Diona"]  
    index2["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    id3["[2]"]  
    key3["Key: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block  
    columns 1  
    id4["[3]"]  
    key4["Key: -"]  
    index4["IdealIndex: -"]  
end  

これで削除処理は完了となります。

Tombstone 法について

Open Addressing での削除については、もうひとつ Tombstone 法という方式があります。
まず、 UsedFlags に 1 つ要素を追加します。

private enum UsedFlags : byte  
{  
    Empty = 0,  
    Used = 1,  
    Tombstone = 2,  
}  

Tombstone は、削除時に付与されるフラグです。これを使った削除処理は以下のようになります。

private bool TryRemove(TKey key)  
{  
    int entryIndex = GetEntryIndex(key);  
  
    if (entryIndex == -1)  
    {  
        return false;  
    }  
  
  
    m_Entries[entryIndex].Key = default!;  
    m_Entries[entryIndex].Value = default!;  
    m_Entries[entryIndex].Used = UsedFlags.Tombstone;  
  
    m_Count--;  
    return true;  
}  

とても簡単です。 Empty にする代わりに Tombstone にします。
そして、検索処理では Tombstone を Used と同等に、追加処理では Empty と同様に扱います。
各キーの位置を詰める必要がないので、単純な実装で済む利点があります。

しかし、多数の追加と削除が走ると Tombstone でいっぱいになり、検索時間が O(n) に近づいてしまう問題があります。 (Empty が見つかったら early return する実装のため)
余談ですが、最初これで実装したらベンチマークが永遠に終わらない問題が発生してしまい、実装を変えたという裏話があります。

Hopscotch Hashing

ここまでは基本的な方式の紹介でしたが、ここからは独自の工夫が見えてきます。

Hopscotch Hashing は、 Open Addressing 方式を改良したもので、ビットマップを利用して探索を高速化した方式です。*1

テーブルが埋まってくると、 Separate Chaining ではチェーンが長くなりがち、 Open Addressing (Linear Probing) では塊ができがちで探索に時間がかかる傾向がありました。しかし、 Hopscotch Hasing ではそのような状況でもパフォーマンスが落ちにくいそうです。

データの持ち方はこんな感じです。

private struct Entry  
{  
    public TKey Key;  
    public TValue Value;  
    public ulong Bitmap;  
}  
  
private Entry[] m_Entries;  
private int m_Count;  
block-beta  
columns 4  
  
block  
    columns 1  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    bitmap1["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    bitmap2["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    bitmap3["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    key4["Key: -"]  
    value4["Value: -"]  
    bitmap4["Bitmap: 0b0000"]  
end  

他との違いは、エントリ内に Bitmap を持つ点です。
この Bitmap は、 0 bit 目が「そのインデックスにそのハッシュ値の要素があるか」 (≒使用中フラグ) 、 1 bit 目が「次のインデックスにそのハッシュ値の要素があるか」、 2 bit 目は「次の次のインデックスに以下略」、…… を表します。

分かりにくいので、実例を示してみましょう。
Barbara と衝突するキー Diona を挿入しようとしたときのことを考えます。
Open Addressing と同様、隣接する空きを探索していくことになります。結果として次の次の要素が空いていますね。そこに Diona を格納します。

block-beta  
columns 4  
  
block  
    columns 1  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    bitmap1["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    bitmap2["Bitmap: 0b0101"]  
end  
  
block  
    columns 1  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    bitmap3["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    key4["Key: Diona"]  
    value4["Value: Diona"]  
    bitmap4["Bitmap: 0b0001"]  
end  
  
space space placeholder[" "]  
style placeholder display:none;  
  
bitmap2 --- placeholder   
placeholder --> bitmap4  

それと同時に、 Barbara の Bitmap を 0b0101 に更新しました。
ここの 0b0001 は Barbara 自身の存在フラグ、 0b0100 が「2 要素先に同一ハッシュインデックスの要素 (= Diona) がある」ことを示すフラグです。

したがって、探索するときはまず Barbara を見る → 次にビットフラグが立っている 2 要素先 = Diona を見て、正解なのでそれを返す、といった感じに処理されます。

また、「あるハッシュ値の要素は、当初のインデックスから 64 個以内の範囲に存在する」という制約を設けます。この 64 はビットマップのビット数に由来します。
この制約によって、少なくとも 64 回の探索で要素が発見できることになります。
それを具体的にどう実現しているかを見ていきましょう。

探索 (Get)

private int GetEntryIndex(TKey key)  
{  
    if (key == null)  
        throw new ArgumentNullException(nameof(key));  
  
    int hashCode = key.GetHashCode();  
    int bucketIndex = GetBucketIndex(hashCode, m_Entries.Length);  
  
    // bitmap のフラグが立っている箇所だけチェックする  
    ulong bitmap = m_Entries[bucketIndex].Bitmap;  
    int mask = m_Entries.Length - 1;  
    for (int i = 0; i < 64; i++)  
    {  
        if ((bitmap & (1ul << i)) != 0)  
        {  
            int next = (bucketIndex + i) & mask;  
            if (EqualityComparer<TKey>.Default.Equals(m_Entries[next].Key, key))  
            {  
                return next;  
            }  
        }  
    }  
  
    return -1;  
}  

Bitmap のフラグが立っている箇所だけキーを比較し、合っていればそれを返します。
ご覧の通り、少なくとも 64 回の探索でキーを見つけることができます。

追加 (Add)

private bool TryAdd(TKey key, TValue value, bool overwrite)  
{  
    int entryIndex = GetEntryIndex(key);  
  
    // 既存なら上書きを試みる  
    if (entryIndex != -1)  
    {  
        if (overwrite)  
        {  
            m_Entries[entryIndex].Value = value;  
            return true;  
        }  
        else  
        {  
            return false;  
        }  
    }  
  
    // 既存でなければ追加  
    AddEntry(key, value);  
    return true;  
}  
  
// 追加  
private void AddEntry(TKey key, TValue value)  
{  
    if (key == null)  
        ThrowArgumentNull(nameof(key));  
  
    int hashCode = key.GetHashCode();  
  
    // 溢れそうなら拡張  
    if (m_Count >= m_Entries.Length)  
    {  
        Resize(m_Entries.Length << 1);  
    }  
  
    int bucketIndex = GetBucketIndex(hashCode, m_Entries.Length);  
    int mask = m_Entries.Length - 1;  
  
  
    // ビットマップが埋まっている場合、拡張して分散することを祈る  
    while (m_Entries[bucketIndex].Bitmap == ~0ul)  
    {  
        Resize(m_Entries.Length << 1);  
        bucketIndex = GetBucketIndex(hashCode, m_Entries.Length);  
        mask = m_Entries.Length - 1;  
    }  
  
    // 未使用領域を探す  
    for (int distance = 0; distance < m_Entries.Length; distance++)  
    {  
        int nextIndex = (bucketIndex + distance) & mask;  
        // 未使用領域を見つけたら  
        if ((m_Entries[nextIndex].Bitmap & 1) == 0)  
        {  
            // 距離 64 未満まで交換しながら持ってくる  
            while (distance >= 64)  
            {  
                for (int shift = -63; shift < 0; shift++)  
                {  
                    int shiftIndex = (bucketIndex + distance + shift) & mask;  
                    ulong shiftBitmap = m_Entries[shiftIndex].Bitmap;  
  
                    if (shiftBitmap >= 1ul << -shift)  
                        continue;  
  
                    for (int b = 0; b < 64; b++)  
                    {  
                        if ((shiftBitmap & (1ul << b)) != 0)  
                        {  
                            shiftBitmap &= ~(1ul << b);  
                            shiftBitmap |= 1ul << -shift;  
                            break;  
                        }  
                    }  
                    m_Entries[shiftIndex].Bitmap = shiftBitmap;  
                    (m_Entries[shiftIndex].Key, m_Entries[nextIndex].Key) = (m_Entries[nextIndex].Key, m_Entries[shiftIndex].Key);  
                    (m_Entries[shiftIndex].Value, m_Entries[nextIndex].Value) = (m_Entries[nextIndex].Value, m_Entries[shiftIndex].Value);  
                    distance += shift;  
                    break;  
                }  
            }  
  
            // 要素の追加  
            m_Entries[bucketIndex].Bitmap |= 1ul << distance;  
            m_Entries[nextIndex].Bitmap |= 1ul;  
            m_Entries[nextIndex].Key = key;  
            m_Entries[nextIndex].Value = value;  
            break;  
        }  
    }  
  
    m_Count++;  
}  

溢れそうな場合には拡張する点は変わりませんが、そのほかに Bitmap が完全に埋まっている場合も拡張を行います。
拡張することでハッシュ値→インデックスの対応が変わって分散することを 祈ります 。
祈りの内容については後述します。

拡張が終わったら、未使用領域を線形に探します。見つけた未使用領域が当初のインデックスから距離 64 未満であれば、そのままそこを使います。
距離 64 以上であれば、動かせる要素を探し、それと未使用領域を交換して距離 64 未満まで持ってきます。

block-beta  
  
block  
    columns 1  
    index1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    bitmap1["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    index2["[1]"]  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    bitmap2["Bitmap: 0b0001"]  
end  
  
block  
    ......  
end  
  
block  
    columns 1  
    index63["[63]"]  
    key63["Key: Zhongli"]  
    value63["Value: Zhongli"]  
    bitmap63["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    index64["[64]"]  
    key64["Key: -"]  
    value64["Value: -"]  
    bitmap64["Bitmap: 0b0000"]  
end  

例えば、上図の状況で Alice と衝突する Ayaka を挿入したい場合を考えます。 ...... で省略されている部分にもぎっしり要素が詰まっているものと考えてください。
Alice ([0]) から最寄りの空きは [64] ですので、距離が遠すぎます。なのでまずはこの空間を近くに移動させる必要があります。

block-beta  
  
block  
    columns 1  
    index1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    bitmap1["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    index2["[1]"]  
    key2["Key: -"]  
    value2["Value: -"]  
    bitmap2["Bitmap: 0b10...00"]  
end  
  
block  
    ......  
end  
  
block  
    columns 1  
    index63["[63]"]  
    key63["Key: Zhongli"]  
    value63["Value: Zhongli"]  
    bitmap63["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    index64["[64]"]  
    key64["Key: Barbara"]  
    value64["Value: Barbara"]  
    bitmap64["Bitmap: 0b0001"]  
end  

手近な Barbara が移動可能だったので、空き [64] と位置を交換しました。対応してビットマップの更新も行っています。
これで空間が近くに来たので、

block-beta  
  
block  
    columns 1  
    index1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    bitmap1["Bitmap: 0b0011"]  
end  
  
block  
    columns 1  
    index2["[1]"]  
    key2["Key: Ayaka"]  
    value2["Value: Ayaka"]  
    bitmap2["Bitmap: 0b10...01"]  
end  
  
block  
    ......  
end  
  
block  
    columns 1  
    index63["[63]"]  
    key63["Key: Zhongli"]  
    value63["Value: Zhongli"]  
    bitmap63["Bitmap: 0b0001"]  
end  
  
block  
    columns 1  
    index64["[64]"]  
    key64["Key: Barbara"]  
    value64["Value: Barbara"]  
    bitmap64["Bitmap: 0b0001"]  
end  

Ayaka のデータを挿入したら完了です。

ここでは、 0 bit 目 (1 の位) を使用フラグと兼用させています。本当は「ここに対応する要素があるか」と「使用中かどうか」は厳密には異なるのですが、実装が簡単になるのでこのようにしています。

æ‹¡å¼µ (Resize)

private void Resize(int size)  
{  
    var prevEntries = m_Entries;  
  
    // 新しいバッファを確保  
    m_Entries = ArrayPool<Entry>.Shared.Rent(size);  
    m_Entries.AsSpan().Clear();  
    m_Count = 0;  
  
    // 使用中フラグが立っている要素を移植  
    for (int i = 0; i < prevEntries.Length; i++)  
    {  
        if ((prevEntries[i].Bitmap & 1) != 0)  
        {  
            AddEntry(prevEntries[i].Key, prevEntries[i].Value);  
        }  
    }  
  
    // 古いバッファの解放  
    ArrayPool<Entry>.Shared.Return(prevEntries, true);  
}  

使用中フラグが立っているものを移植しています。

削除 (Delete)

private bool RemoveEntry(TKey key)  
{  
    int bucketIndex = GetBucketIndex(key.GetHashCode(), m_Entries.Length);  
    int entryIndex = GetEntryIndex(key);  
  
    // 見つからなければ終了  
    if (entryIndex == -1)  
        return false;  
  
    // 要素の削除  
    m_Entries[entryIndex].Key = default!;  
    m_Entries[entryIndex].Value = default!;  
    m_Entries[entryIndex].Bitmap &= ~1ul;  
  
    // 対応するビットマップの削除  
    ulong bitmap = m_Entries[bucketIndex].Bitmap;  
    bitmap &= ~(1ul << (entryIndex - bucketIndex));  
    m_Entries[bucketIndex].Bitmap = bitmap;  
  
    m_Count--;  
    return true;  
}  

見つかったら要素の削除とビットマップからの削除を行います。

Hopscotch Hashing でビットマップが埋まった時

さて、 Bitmap が埋まってしまったときはテーブルを拡大して要素が分散するのを 祈ります 、と以前言いました。なぜ祈っていたのかというと、テーブルを拡大しても解決しない場合があるためです。
ハッシュ値そのものが同一の要素が 64 個以上存在した場合、いくらテーブルを拡大しても同一のエントリに 64 個以上割り当てられることになり、無限にテーブルを拡大していくことになります。
したがって、メモリがパンクするまでテーブルを拡大したのち落ちます。具体的には 2^{30} 個ぶん確保したあと int がオーバーフローして例外を吐くまでですね。なかなか派手な壊れ方です……!

通常のユースケースにおいては 64 個もハッシュ値が同じになることは滅多にありません。 GUID の衝突みたいなもので、杞憂と言ってもよいでしょう。しかし、ユーザが任意の値を追加できる場合においては問題になる可能性があります。
悪意のあるユーザが同一ハッシュ値になるように細工をした文字列等を投げ続けることで、このアルゴリズムを「破壊」することができるわけです。

さすがに破壊されると困るので、対策を考えてみましょう。
一番安直な方法として、「63 個めの bit が立っているときは、63 個先のビットマップを参照して探索を続行する」ように改造する手があります。
これによって「64 回以内に探索が終了する」利点は失われてしまいますが、少なくともテーブルを拡大し続けて爆発するようなことは起きなくなります。

他には、専用のオーバーフローリストに飛ばすような実装 もあるようです。

Robin Hood Hashing

Robin Hood Hashing は、 Hopscotch Hashing と同様に Open Addressing 方式を改良したものですが、アプローチが異なります。 *2

具体的には、本来入るはずだったインデックスを保持しておいて、挿入時にはなるべく本来の位置から近くなるようにデータを交換していくことで探索時間を減らしています。

ちなみに、以前の Rust では Robin Hood Hashing を採用していた ようです。

データの持ち方は以下の通りです。

private readonly struct Entry  
{  
    public readonly TKey Key;  
    public readonly TValue Value;  
    // 本来入るはずだったインデックス。 -1=未使用  
    public readonly int IdealIndex;  
  
    public bool IsEmpty() => IdealIndex == -1;  
}  
  
private Entry[] m_Entries;  
private int m_Count;  
block-beta  
columns 4  
  
block  
    columns 1  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    index1["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    index2["IdealIndex: 1"]  
end  
  
block  
    columns 1  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block  
    columns 1  
    key4["Key: -"]  
    value4["Value: -"]  
    index4["IdealIndex: -1"]  
end  

探索 (Get)

private int GetEntryIndex(TKey key)  
{  
    int mask = m_Entries.Length - 1;  
    int hashCode = key.GetHashCode();  
  
    for (int offset = 0; offset < m_Entries.Length; offset++)  
    {  
        int index = (hashCode + offset) & mask;  
  
        // 空きがあれば探索終了 (密に詰まっているため)  
        if (m_Entries[index].IsEmpty())  
            return -1;  
  
        // 一定距離離れたら終了  
        if (offset > ((index - m_Entries[index].IdealIndex + m_Entries.Length) & mask))  
            return -1;  
  
        // key と等しければ発見  
        if (EqualityComparer<TKey>.Default.Equals(m_Entries[index].Key, key))  
        {  
            return index;  
        }  
    }  
  
    return -1;  
}  

// 一定距離離れたら終了 については、現在地における理想との距離が探索範囲を超えている場合、なるべく理想との距離が近くなるようにする制約に反する=今探している項目ではないことが分かるため、このような枝刈りができます。
具体例を挙げてみましょう。以下の状態で、 Alice と衝突するインデックスの Ayaka を探索したいとします。

block-beta  
columns 4  
  
block  
    columns 1  
    id1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    index1["IdealIndex: 0"]  
end  
  
block  
    columns 1  
    id2["[1]"]  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    index2["IdealIndex: 1"]  
end  
  
block  
    columns 1  
    id3["[2]"]  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block  
    columns 1  
    id4["[3]"]  
    key4["Key: -"]  
    value4["Value: -"]  
    index4["IdealIndex: -1"]  
end  

[0] の Alice は当然違うので次の [1] に進むのですが、この時探索距離 ([0] からの距離 = 1) よりも [1] の要素の理想との距離 (Barbara の IdealIndex - [1] = 0) のほうが小さいため、 Ayaka は存在しないことが分かります。

追加 (Add)

private bool AddEntry(TKey key, TValue value, bool overwrite)  
{  
    // 溢れるなら拡張  
    if (m_Count >= m_Entries.Length)  
    {  
        Resize(m_Entries.Length << 1);  
    }  
  
    int hashCode = key.GetHashCode();  
    int mask = m_Entries.Length - 1;  
  
    for (int offset = 0; offset < m_Entries.Length; offset++)  
    {  
        int index = (hashCode + offset) & mask;  
  
        // 空の要素を見つけたらそこに挿入  
        if (m_Entries[index].IsEmpty())  
        {  
            m_Entries[index] = new Entry(key, value, hashCode & mask);  
            m_Count++;  
            return true;  
        }  
  
        // 重複していた場合上書き  
        if (EqualityComparer<TKey>.Default.Equals(m_Entries[index].Key, key))  
        {  
            if (overwrite)  
            {  
                m_Entries[index] = new Entry(key, value, hashCode & mask);  
                return true;  
            }  
            else  
            {  
                return false;  
            }  
        }  
  
        // 一定距離離れた場合、挿入対象を交換して挿入処理をやり直す  
        if (offset > ((index - m_Entries[index].IdealIndex) & mask))  
        {  
            Entry temp = new Entry(key, value, hashCode & mask);  
            (m_Entries[index], temp) = (temp, m_Entries[index]);  
            key = temp.Key;  
            value = temp.Value;  
            hashCode = key.GetHashCode();  
            offset = 0;  
            continue;  
        }  
    }  
  
    return false;  
}  

また一定距離離れた場合の処理が出てきました。
現在地における理想との距離が探索距離より小さい場合、その要素と挿入対象を交換して、挿入処理を再度開始します。
理想からの距離が短いほうが「価値が高い」と考えると、「価値が高い」現在の要素から「価値が低い」挿入中の要素に位置を明け渡させるという、義賊 ロビンフッド のイメージです。

具体例として、以下の図に Alice とハッシュ値のインデックスが衝突する Ayaka を挿入する場合のことを考えましょう。

block-beta  
columns 4  
  
block:block1  
    columns 1  
    id1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    index1["IdealIndex: 0"]  
end  
  
block:block2  
    columns 1  
    id2["[1]"]  
    key2["Key: Barbara"]  
    value2["Value: Barbara"]  
    index2["IdealIndex: 1"]  
end  
  
block:block3  
    columns 1  
    id3["[2]"]  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block:block4  
    columns 1  
    id4["[3]"]  
    key4["Key: -"]  
    value4["Value: -"]  
    index4["IdealIndex: -1"]  
end  
  
space:4  
  
block:block5  
    columns 1  
    key5["Key: Ayaka"]  
    value5["Value: Ayaka"]  
    index5["IdealIndex: 0"]  
end  
  
block5 --> block1  

まず Alice にはもう入っているので Barbara を見に行きます。
ここで、探索距離 (1) より理想の位置からの距離 (0) は小さいので、 Barbara と Ayaka を交換します。

block-beta  
columns 4  
  
block:block1  
    columns 1  
    id1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    index1["IdealIndex: 0"]  
end  
  
block:block2  
    columns 1  
    id2["[1]"]  
    key2["Key: Ayaka"]  
    value2["Value: Ayaka"]  
    index2["IdealIndex: 0"]  
end  
  
block:block3  
    columns 1  
    id3["[2]"]  
    key3["Key: Charlotte"]  
    value3["Value: Charlotte"]  
    index3["IdealIndex: 2"]  
end  
  
block:block4  
    columns 1  
    id4["[3]"]  
    key4["Key: -"]  
    value4["Value: -"]  
    index4["IdealIndex: -1"]  
end  
  
space:4  
space  
  
block:block5  
    columns 1  
    key5["Key: Barbara"]  
    value5["Value: Barbara"]  
    index5["IdealIndex: 1"]  
end  
  
block2 --> block5  
block5 --> block2  

続いて、 Charlotte も探索距離 (1) より理想との距離 (0) が小さいので、 Charlotte と Barbara を交換します。

block-beta  
columns 4  
  
block:block1  
    columns 1  
    id1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    index1["IdealIndex: 0"]  
end  
  
block:block2  
    columns 1  
    id2["[1]"]  
    key2["Key: Ayaka"]  
    value2["Value: Ayaka"]  
    index2["IdealIndex: 0"]  
end  
  
block:block3  
    columns 1  
    id3["[2]"]  
    key3["Key: Barbara"]  
    value3["Value: Barbara"]  
    index3["IdealIndex: 1"]  
end  
  
block:block4  
    columns 1  
    id4["[3]"]  
    key4["Key: -"]  
    value4["Value: -"]  
    index4["IdealIndex: -1"]  
end  
  
space:4  
space:2  
  
block:block5  
    columns 1  
    key5["Key: Charlotte"]  
    value5["Value: Charlotte"]  
    index5["IdealIndex: 2"]  
end  
  
block3 --> block5  
block5 --> block3  

続く領域は空き領域なので、 Charlotte をそのまま突っ込んで終了です。

block-beta  
columns 4  
  
block:block1  
    columns 1  
    id1["[0]"]  
    key1["Key: Alice"]  
    value1["Value: Alice"]  
    index1["IdealIndex: 0"]  
end  
  
block:block2  
    columns 1  
    id2["[1]"]  
    key2["Key: Ayaka"]  
    value2["Value: Ayaka"]  
    index2["IdealIndex: 0"]  
end  
  
block:block3  
    columns 1  
    id3["[2]"]  
    key3["Key: Barbara"]  
    value3["Value: Barbara"]  
    index3["IdealIndex: 1"]  
end  
  
block:block4  
    columns 1  
    id4["[3]"]  
    key4["Key: Charlotte"]  
    value4["Value: Charlotte"]  
    index4["IdealIndex: 2"]  
end  

æ‹¡å¼µ (Resize)

private void Resize(int newCapacity)  
{  
    // バッファの退避と確保  
    var oldEntries = m_Entries;  
    m_Entries = ArrayPool<Entry>.Shared.Rent(newCapacity);  
    m_Entries.AsSpan().Fill(new Entry(default!, default!, -1));  
  
    int mask = m_Entries.Length - 1;  
  
    for (int i = 0; i < oldEntries.Length; i++)  
    {  
        if (!oldEntries[i].IsEmpty())  
        {  
            // 挿入処理を行う (既存チェックは省略)  
            var inserting = oldEntries[i];  
            int hashCode = inserting.Key.GetHashCode();  
  
            for (int offset = 0; offset < m_Entries.Length; offset++)  
            {  
                int index = (hashCode + offset) & mask;  
                if (m_Entries[index].IsEmpty())  
                {  
                    m_Entries[index] = new Entry(inserting.Key, inserting.Value, hashCode & mask);  
                    break;  
                }  
  
                if (offset > ((index - m_Entries[index].IdealIndex + m_Entries.Length) & mask))  
                {  
                    var swap = m_Entries[index];  
                    m_Entries[index] = new Entry(inserting.Key, inserting.Value, hashCode & mask);  
                    inserting = swap;  
  
                    hashCode = inserting.Key.GetHashCode();  
                    offset = 0;  
                    continue;  
                }  
            }  
        }  
    }  
  
    // 旧バッファの解放  
    ArrayPool<Entry>.Shared.Return(oldEntries, true);  
}  

単に挿入処理を繰り返している感じです。キーが重複する心配はないので、探索処理を省略しています。

削除 (Remove)

private bool RemoveEntry(TKey key)  
{  
    int hashCode = key.GetHashCode();  
    int mask = m_Entries.Length - 1;  
  
    bool running = false;  
  
    for (int offset = 0; offset < m_Entries.Length; offset++)  
    {  
        int index = (hashCode + offset) & mask;  
  
        // 削除するエントリを探索する (get と同様)  
        if (!running)  
        {  
            if (m_Entries[index].IsEmpty())  
                return false;  
  
            if (offset > ((index - m_Entries[index].IdealIndex + m_Entries.Length) & mask))  
                return false;  
  
            if (EqualityComparer<TKey>.Default.Equals(m_Entries[index].Key, key))  
            {  
                running = true;  
            }  
        }  
  
        // 削除するエントリに続く、空き or 理想の場所にあるエントリまでを探索  
        if (running)  
        {  
            int next = (hashCode + offset + 1) & mask;  
            if (m_Entries[next].IsEmpty() || m_Entries[next].IdealIndex == next)  
            {  
                m_Entries[index] = new Entry(default!, default!, -1);  
                break;  
            }  
  
            // エントリを前に詰める  
            m_Entries[index] = m_Entries[next];  
        }  
    }  
  
    m_Count--;  
    return true;  
}  

削除するエントリを単に IdealIndex = -1 として無効化する方法 (Tombstone 法) もあるのですが、探索を高速化するため、ちょっと手間をかけて空きを詰める方式を採用します。

削除対象のエントリから始まり、空き領域または理想の位置にあるエントリで終わる (含まない) 領域を前に詰めます。
墓石を埋め込む方法は削除は簡単なのですが、探索の際に墓石を読み飛ばす処理が挟まるので遅くなります。こうすることで探索を単純にして高速化しているというわけです。

SwissTable

SwissTable は、 Abseil という Google が開発した C++ ライブラリ内で使われているアルゴリズムです。
これも Open Addressing 方式を改良したものなのですが、 SIMD や bit 演算を駆使することで高速な探索を実現しています。

本稿では、 マルチプラットフォーム対応が面倒だったので Unity とふつうの .NET 環境での併用を考えるため、SIMD を用いない形式のものを取り上げます。もちろん、 SIMD 対応版のほうが高速に動作するでしょうから、興味のある方は C への移植実装 などをご参照ください。

データの持ち方はこんな感じです。

// メタデータ/制御情報  
// 0x00 ~ 0x7f: 有効データ、ハッシュ値下位 7 bit(fingerprint)  
// 0x80: 空き  
// 0xfe: 削除済み (tombstone)  
// 0xff: 末尾チェック用 (sentinel)  
private sbyte[] m_ControlBytes;  
// Key/Value  
private KeyValuePair<TKey, TValue>[] m_Slots;  
// 要素数  
private int m_Size;  
// キャパシティ (実際に使える最大容量。2^n - 1)  
private int m_Capacity;  
// テーブルを拡大するまでの残り要素(≒空きスロット)数  
private int m_GrowthLeft;  

今までのように Entry 構造体にまとめずに、 m_ControlBytes が独立した配列になっているのがポイントです。
SIMD や bit 操作を行う際に m_ControlBytes が連続したメモリ上にあるほうが都合がいいのです。

block-beta  
columns 1  
  
block  
    columns 5  
    cb{{"ControlBytes"}} 0x00 0x01 0x02 0x80  
end  
  
block  
    columns 5  
    key0{{"Keys"}}  
    key1["Alice"]  
    key2["Barbara"]  
    key3["Charlotte"]  
    key4["-"]  
    value0{{"Values"}}  
    value1["Alice"]  
    value2["Barbara"]  
    value3["Charlotte"]  
    value4["-"]  
end  

探索 (Get)

private int Find(TKey key)  
{  
    int hash = EqualityComparer<TKey>.Default.GetHashCode(key);  
  
    // 探査用状態の初期化  
    int probeSeqMask = m_Capacity;  
    int probeSeqOffset = Hash1(hash) & probeSeqMask;  
    int probeSeqIndex = 0;  
  
    while (true)  
    {  
        // グループ単位で探査  
        var group = MemoryMarshal.Cast<sbyte, ulong>(  
            m_ControlBytes.AsSpan(probeSeqOffset..))[0];  
        var match = GroupMatch(group, Hash2(hash));  
  
        // グループ内を探査  
        while (BitMaskNext(ref match, out int i))  
        {  
            int slotOffset = (probeSeqOffset + i) & probeSeqMask;  
  
            // 一致する項目があればそれを返す  
            if (EqualityComparer<TKey>.Default.Equals(  
                key, m_Slots[slotOffset].Key))  
            {  
                return slotOffset;  
            }  
        }  
  
        // 空きがあれば「見つからなかった」として終了  
        if (MatchEmptyGroup(group) != 0)  
        {  
            return -1;  
        }  
  
        // 探査状態を更新、次の要素へ進める  
        probeSeqIndex += GroupWidth;  
        probeSeqOffset += probeSeqIndex;  
        probeSeqOffset &= probeSeqMask;  
  
        Debug.Assert(probeSeqIndex <= m_Capacity);  
    }  
}  
  
// hash2 に一致するバイトを抽出する  
private static ulong GroupMatch(ulong group, sbyte hash2)  
{  
    ulong msbs = 0x8080808080808080;  
    ulong lsbs = 0x0101010101010101;  
    ulong x = group ^ (lsbs * (byte)hash2);  
    return (x - lsbs) & ~x & msbs;  
}  
  
// 次にビットが立っている位置を取得して bit に設定  
private static bool BitMaskNext(ref ulong bitmask, out int bit)  
{  
    if (bitmask == 0)  
    {  
        bit = 0;  
        return false;  
    }  
  
    bit = TrailingZeroCount(bitmask) >> GroupShift;  
    bitmask &= bitmask - 1;  
    return true;  
}  
  
// 空き領域のバイトを抽出する  
private static ulong MatchEmptyGroup(ulong group)  
{  
    ulong msbs = 0x8080808080808080;  
    return group & (~group << 6) & msbs;  
}  

なんかビット魔術が使われていることは分かりますね。
上から順に見ていきましょう。

        // グループ単位で探査  
        var group = MemoryMarshal.Cast<sbyte, ulong>(  
            m_ControlBytes.AsSpan(probeSeqOffset..))[0];  
        var match = GroupMatch(group, Hash2(hash));  

var group = ... の行は、要するに [probeSeqOffset] から 8 個分 (ulong は 8 バイトなので) の要素を取得しています。
C 言語でいう *(uint64_t *)(m_ControlBytes + probeSeqOffset) みたいな感じです。
GroupMatch では何をしているのかというと、 hash2 に一致するバイトにフラグを立てています。

private static ulong GroupMatch(ulong group, sbyte hash2)  
{  
    ulong msbs = 0x8080808080808080;  
    ulong lsbs = 0x0101010101010101;  
    ulong x = group ^ (lsbs * (byte)hash2);  
    return (x - lsbs) & ~x & msbs;  
}  

具体例を挙げて見てみましょう。

hash        = 0x2b                  // 例えば hash がこの値のとき  
lsbs * hash = 0x2b2b2b2b2b2b2b2b  
groups      = 0x80802b8080802b80    // こういう値だったとして  
x           = 0xabab00ababab00ab    // hash と一致したバイトは 0x00 になる  
x - lsbs    = 0xaaa9ffaaaaa9ffaa    // x の 0x00 が 0xff になる  
~x          = 0x5454ff545454ff54    // x の 0x00 が 0xff になる  
return      = 0x0000800000008000    // マッチしたバイトの msb (0x80) が立つ  

といった感じで、 hash に一致したバイトに 0x80 が立ち、それ以外には 0x00 が設定されます。鮮やか……!

どうして x - lsbs と ~x の二段構えにしているのかというと、 x - lsbs では x のバイトが 0xfe だった場合に 0xfd となり最上位ビットが立ったままになってしまいますが、 ~x なら 0xfe は 0x01 になるので、これと and をとることで最上位ビットを折ることができます。

なお、この方式だと若干の誤検出があるようです。詳しくは こちらの記事 に譲ります。
それでも、多少比較が増えるだけで結果自体には影響がないようにできているようです。かしこい。

この式は基本的に SIMD 版を移植したみたいな感じなので、 SIMD 版では誤検出は発生しませんし、 SIMD なら 2 倍の領域のチェックを一気に行えるので高速です。今回のように特別な理由がなければ SIMD 版を使うのが良いでしょう。ふつうの .NET 環境なら System.Numerics.Vector128 で、 Unity なら Burst とかで実装するとよさそうです。

private static bool BitMaskNext(ref ulong bitmask, out int bit)  
{  
    if (bitmask == 0)  
    {  
        bit = 0;  
        return false;  
    }  
  
    bit = TrailingZeroCount(bitmask) >> GroupShift;  
    bitmask &= bitmask - 1;  
    return true;  
}  

BitMaskNext は、フラグが立っているビットの位置を検出するメソッドです。
まずは TrailingZeroCount で、最下位から続くビット 0 の数を数えます。ここでは GroupShift は定数 3 ですので、 >> 3 は / 8 と同じ、つまり最下位から続くバイト 0x00 の数を数えています。これは言い換えれば、 0x00 ではない = フラグが立っているバイトのインデックスを求めています。
そして、 bitmask &= bitmask - 1 は、一番下の 1 ビットをひとつ 0 にするイディオムです。これでフラグを折ることで、次のループでその次の 1 のインデックスを得ているというわけです。

// 空き領域のバイトを抽出する  
private static ulong MatchEmptyGroup(ulong group)  
{  
    ulong msbs = 0x8080808080808080;  
    return group & (~group << 6) & msbs;  
}  

これは空き領域 (0x80) を検出するコードです。
0x80 が立っていて、 0x02 が立っていないバイトを取得しています。
0x02 をはじくことで、削除済み領域 (0xfe) や番兵 (0xff) を除外しています。

ここで、なぜ空き領域の検出によって探索を終了しているのかというと、要素は理想の位置から始まって密に詰まっているはずだという前提があるためです。

        // 探査状態を更新、次の要素へ進める  
        probeSeqIndex += GroupWidth;  
        probeSeqOffset += probeSeqIndex;  
        probeSeqOffset &= probeSeqMask;  

ここで、 probeSeqOffset は以下の式で更新されます。

  
probeSeqOffset(i) = GroupWidth \times \frac{1}{2} i(i - 1) + Hash \pmod{probeSeqMask + 1}

probeSeqOffset が等差数列の和になっていることに注目してください。これによって、 Linear Probing ではなく Quadratic Probing に近い方式 (Triangular Probing?) になっています。

また、この式はループするまでにすべてのグループを 1 回だけ通過することが保証されています。 *3

追加 (Add)

private bool AddEntry(TKey key, TValue value, bool overwrite)  
{  
    var result = FindOrPrepareInsert(key);  
    if (result.inserted || overwrite)  
    {  
        m_Slots[result.index] = new KeyValuePair<TKey, TValue>(key, value);  
        return true;  
    }  
  
    return false;  
}  
  
// inserted: 既に同一キーの要素が存在していれば false  
private (int index, bool inserted) FindOrPrepareInsert(TKey key)  
{  
    int hash = EqualityComparer<TKey>.Default.GetHashCode(key);  
  
    int probeSeqMask = m_Capacity;  
    int probeSeqOffset = Hash1(hash) & probeSeqMask;  
    int probeSeqIndex = 0;  
  
    // 同一キー要素の探索  
    while (true)  
    {  
        var group = MemoryMarshal.Cast<sbyte, ulong>(m_ControlBytes.AsSpan(probeSeqOffset..))[0];  
        var match = GroupMatch(group, Hash2(hash));  
  
        while (BitMaskNext(ref match, out int i))  
        {  
            int index = (probeSeqOffset + i) & probeSeqMask;  
            if (EqualityComparer<TKey>.Default.Equals(key, m_Slots[index].Key))  
            {  
                return (index, false);  
            }  
        }  
  
        if (GroupMatch(group, Empty) != 0)  
        {  
            break;  
        }  
  
        probeSeqIndex += GroupWidth;  
        probeSeqOffset += probeSeqIndex;  
        probeSeqOffset &= probeSeqMask;  
  
        Debug.Assert(probeSeqIndex <= m_Capacity);  
    }  
  
    return (PrepareInsert(hash), true);  
}  
  
// キーが存在しなかった場合、追加できる空きインデックスを求める  
private int PrepareInsert(int hash)  
{  
    // 空きインデックスを求める  
    int target = FindFirstNonFull(hash);  
  
    // 満杯の場合、リサイズする  
    if (m_GrowthLeft == 0 && !IsDeleted(m_ControlBytes[target]))  
    {  
        RehashAndGrowIfNecessary();  
        target = FindFirstNonFull(hash);  
    }  
  
    // 空き→使用済みへ  
    m_Size++;  
    m_GrowthLeft -= IsEmpty(m_ControlBytes[target]) ? 1 : 0;  
    SetControlByte(target, Hash2(hash), m_Capacity);  
  
    return target;  
}  
  
// 空き or 削除済み要素を探索  
private int FindFirstNonFull(int hash)  
{  
    int probeSeqMask = m_Capacity;  
    int probeSeqOffset = Hash1(hash) & probeSeqMask;  
    int probeSeqIndex = 0;  
  
    while (true)  
    {  
        // 空き or 削除済み要素を含むグループを取得  
        var group = MemoryMarshal.Cast<sbyte, ulong>(m_ControlBytes.AsSpan(probeSeqOffset..))[0];  
        var match = MatchEmptyOrDeletedGroup(group);  
        if (match != 0)  
        {  
            int result = (probeSeqOffset + (TrailingZeroCount(match) >> GroupShift)) & probeSeqMask;  
  
            Debug.Assert(IsEmptyOrDeleted(m_ControlBytes[result]));  
            return result;  
        }  
  
        probeSeqIndex += GroupWidth;  
        probeSeqOffset += probeSeqIndex;  
        probeSeqOffset &= probeSeqMask;  
  
        Debug.Assert(probeSeqIndex <= m_Capacity);  
    }  
}  
  
// 空き or 削除済み要素のバイトを抽出  
private static ulong MatchEmptyOrDeletedGroup(ulong group)  
{  
    ulong msbs = 0x8080808080808080;  
    return group & (~group << 7) & msbs;  
}  
  
// m_ControlBytes の更新  
private void SetControlByte(int index, sbyte controlByte, int capacity)  
{  
    Debug.Assert(index < capacity);  
  
    int mirrored = ((index - SizeOfClonedBytes) & capacity) +  
        (SizeOfClonedBytes & capacity);  
  
    m_ControlBytes[index] = controlByte;  
    m_ControlBytes[mirrored] = controlByte;  
}  

基本的には今までのと同様、既存キーの探索→なければ空き領域を探して追加、の流れです。
FindOrPrepareInsert() では既存キーの探索、 PrepareInsert() で空き領域の探索を行っています。
ここでの「空き領域」は、空き領域そのもの 0x80 または削除済み領域 0xfe を指します。

private static ulong MatchEmptyOrDeletedGroup(ulong group)  
{  
    ulong msbs = 0x8080808080808080;  
    return group & (~group << 7) & msbs;  
}  

これは 0x80 が立っていて 0x01 が立っていないバイトを 0x80 に、そうでないバイトを 0x00 にするコードです。
つまり 0x80 と 0xfe を検出できます。

private void SetControlByte(int index, sbyte controlByte, int capacity)  
{  
    Debug.Assert(index < capacity);  
  
    int mirrored = ((index - SizeOfClonedBytes) & capacity) +  
        (SizeOfClonedBytes & capacity);  
  
    m_ControlBytes[index] = controlByte;  
    m_ControlBytes[mirrored] = controlByte;  
}  

SetControlByte() は、単に指定位置にバイトを設定するだけではありません。
mirrored は、 index が SizeOfClonedBytes より小さいときに capacity + 1 + index を、そうでないときは index を指します。 SizeOfClonedBytes は GroupWidth - 1 = 7 です。

実は m_ControlBytes は capacity と同じサイズではなく、末尾に 7 バイトの追加領域を持ちます。ここは常に [0-6] のコピーとなっています。
なぜコピーを持っているのかというと、末尾付近のグループを参照しようとしてループして先頭のデータが必要になった際、分岐などの特殊処理を入れなくてもよくなるためです。

block-beta  
  
columns 4  
  
_[" "] explain1["[0, capacity - 1]"] explain2["[capacity]"] explain3["[capacity + 1, capacity + 7]"]  
cb{{"ControlBytes"}} value1["control bytes"] value2["0xff"] value3["copy of [0, 6]"]  
  
style _ display:none;  

なお、 capacity は常に 2^{n}-1 となります。

末尾に追加領域が必要ということは 2^{n}+7 個分の領域が必要になるわけですが、 ArrayPool<T>.Rent() で得られるのは 2^{n} 単位なので、結構な無駄が出てしまいますね……

æ‹¡å¼µ (Resize)

// リサイズか削除済み領域の整理を行う  
private void RehashAndGrowIfNecessary()  
{  
    if (m_Capacity == 0)  
    {  
        Resize(1);  
    }  
    // size が capacity の約 78% (25/32) 以下なら、削除済み領域の整理  
    else if (m_Capacity > GroupWidth && m_Size * 32L <= m_Capacity * 25L)  
    {  
        DropDeletesWithoutResize();  
    }  
    // そうでなければリサイズ  
    else  
    {  
        Resize(m_Capacity * 2 + 1);  
    }  
}  
  
// リサイズ  
private void Resize(int newCapacity)  
{  
    // 2^n-1 に限定する  
    Debug.Assert(((newCapacity + 1) & newCapacity) == 0 && newCapacity > 0);  
  
    // 旧バッファの退避と新バッファの確保  
    int oldCapacity = m_Capacity;  
    m_Capacity = newCapacity;  
    var oldControlBytes = m_ControlBytes;  
    var oldSlots = m_Slots;  
    InitializeSlots(newCapacity);  
  
    // 有効データの移植  
    for (int i = 0; i < oldCapacity; i++)  
    {  
        if (IsFull(oldControlBytes[i]))  
        {  
            int hash = EqualityComparer<TKey>.Default.GetHashCode(oldSlots[i].Key);  
            int target = FindFirstNonFull(hash);  
  
            SetControlByte(target, Hash2(hash), m_Capacity);  
            m_Slots[target] = oldSlots[i];  
        }  
    }  
  
    // 旧バッファの削除  
    if (oldCapacity != 0)  
    {  
        ArrayPool<sbyte>.Shared.Return(oldControlBytes);  
        ArrayPool<KeyValuePair<TKey, TValue>>.Shared.Return(oldSlots);  
    }  
}  
  
// 削除済み領域の解放を行う  
private void DropDeletesWithoutResize()  
{  
    Debug.Assert(((m_Capacity + 1) & m_Capacity) == 0 && m_Capacity > 0);  
    Debug.Assert(!IsSmall(m_Capacity));  
  
    // 削除済み→空き、使用中→削除済み  
    ConvertDeletedToEmptyAndFullToDeleted(m_Capacity);  
  
    // 削除済み領域の整理  
    for (int i = 0; i < m_Capacity; i++)  
    {  
        if (!IsDeleted(m_ControlBytes[i]))  
        {  
            continue;  
        }  
  
        int hash = EqualityComparer<TKey>.Default.GetHashCode(m_Slots[i].Key);  
        int target = FindFirstNonFull(hash);  
  
        // 最初に見つかった空き or 削除済みと同一グループにあれば、蘇生させる  
        int probeOffset = Hash1(hash) & m_Capacity;  
        if (((target - probeOffset) & m_Capacity) / GroupWidth ==  
            ((i - probeOffset) & m_Capacity) / GroupWidth)  
        {  
            SetControlByte(i, Hash2(hash), m_Capacity);  
            continue;  
        }  
  
        // 移動先が空き領域だった場合、そこに移動  
        if (IsEmpty(m_ControlBytes[target]))  
        {  
            SetControlByte(target, Hash2(hash), m_Capacity);  
            m_Slots[i] = m_Slots[target];  
            SetControlByte(i, Empty, m_Capacity);  
        }  
        else  
        {  
            // 削除済み領域だった場合、交換して再試行  
            Debug.Assert(IsDeleted(m_ControlBytes[target]));  
            SetControlByte(target, Hash2(hash), m_Capacity);  
  
            (m_Slots[i], m_Slots[target]) = (m_Slots[target], m_Slots[i]);  
            i--;  
        }  
    }  
  
    ResetGrowthLeft();  
}  
  
// 削除済み→空き、使用中→削除済みに変換  
private void ConvertDeletedToEmptyAndFullToDeleted(int capacity)  
{  
    Debug.Assert(m_ControlBytes[capacity] == Sentinel);  
    Debug.Assert(((capacity + 1) & capacity) == 0 && capacity > 0);  
  
    var ulongs = MemoryMarshal.Cast<sbyte, ulong>(m_ControlBytes);  
    for (int i = 0; i < capacity; i += GroupWidth)  
    {  
        ulongs[i >> 3] = ConvertSpecialToEmptyAndFullToDeleted(ulongs[i >> 3]);  
    }  
  
    // クローン領域へのコピー、番兵の再設定  
    m_ControlBytes.AsSpan(..SizeOfClonedBytes).CopyTo(m_ControlBytes.AsSpan((capacity + 1)..));  
    m_ControlBytes[capacity] = Sentinel;  
}  
  
// 削除済み→空き、使用中→削除済み  
private static ulong ConvertSpecialToEmptyAndFullToDeleted(ulong group)  
{  
    ulong msbs = 0x8080808080808080;  
    ulong lsbs = 0x0101010101010101;  
    ulong x = group & msbs;  
    ulong res = (~x + (x >> 7)) & ~lsbs;  
    return res;  
}  
  
// 領域の確保と初期化  
private void InitializeSlots(int capacity)  
{  
    Debug.Assert(capacity > 0);  
  
    int sizeOfControlBytes = capacity + 1 + SizeOfClonedBytes;  
  
    m_ControlBytes = ArrayPool<sbyte>.Shared.Rent(sizeOfControlBytes);  
    m_ControlBytes.AsSpan(..sizeOfControlBytes).Fill(Empty);  
    m_ControlBytes[capacity] = Sentinel;  
  
    m_Slots = ArrayPool<KeyValuePair<TKey, TValue>>.Shared.Rent(capacity);  
  
    ResetGrowthLeft();  
}  
  
// GrowthLeft の再設定  
private void ResetGrowthLeft()  
{  
    int capacityToGrowth = (GroupWidth == 8 && m_Capacity == 7) ? 6 : (m_Capacity - m_Capacity / 8);  
  
    m_GrowthLeft = capacityToGrowth - m_Size;  
}  

RehashAndGrowIfNecessary() では、現在の要素数がキャパシティの約 78% (25/32) 以下なら、実際に拡張せずに削除済み領域の整理を行うことで対処します。

DropDeletesWithoutResize() では、削除済み領域の解放を行います。具体的にどうしているのかを見ていきましょう。
まずは、全ての要素を削除済み→空き領域に、使用済み→削除済みに変更します。
ここでの削除済みはいわば「整理中フラグ」のようなもので、実際削除するわけではなく、後々蘇生させます。
変更したら、削除済みフラグが立っている要素について位置の整理を行います。
理想的な位置に近い(同一グループに所属している)ならそのまま蘇生させます。
遠かった場合は、移動先が空き領域ならそこに移動、削除済みなら要素を交換して再試行します。

private static ulong ConvertSpecialToEmptyAndFullToDeleted(ulong group)  
{  
    ulong msbs = 0x8080808080808080;  
    ulong lsbs = 0x0101010101010101;  
    ulong x = group & msbs;  
    ulong res = (~x + (x >> 7)) & ~lsbs;  
    return res;  
}  

ここもビット演算で並列計算しています。具体例を挙げるとこんな感じです。

group           = 0x010203040580feff  
x               = 0x0000000000808080  
x >> 7          = 0x0000000000010101  
~x              = 0xffffffffff7f7f7f  
(~x + (x >> 7)) = 0xffffffffff808080  
res             = 0xfefefefefe808080  

リサイズが避けられない場合は、 Resize() を実行します。
冒頭の ((newCapacity + 1) & newCapacity) == 0 は、 newCapacity が 2^{n}-1 かどうかを判別するコードです。 (x & (x - 1)) == 0 で 2^{n} かを検出できるのを利用しています。
リサイズ処理については他の Dictionary と同様、使用中の要素を新バッファに移植する処理を行うだけです。

削除 (Delete)

private bool Erase(TKey key)  
{  
    int index = Find(key);  
    if (index == -1)  
        return false;  
  
    Debug.Assert(IsFull(m_ControlBytes[index]));  
    RemoveAt(index);  
  
    return true;  
}  
  
// 指定されたインデックスの要素を削除  
private void RemoveAt(int index)  
{  
    Debug.Assert(IsFull(m_ControlBytes[index]));  
  
    m_Size--;  
  
    // ひとつ前のグループのインデックス  
    int indexBefore = (index - GroupWidth) & m_Capacity;  
  
    // 現在位置とひとつ前のグループの空きを検索  
    var emptyAfter = MatchEmptyGroup(MemoryMarshal.Cast<sbyte, ulong>(m_ControlBytes.AsSpan(index..))[0]);  
    var emptyBefore = MatchEmptyGroup(MemoryMarshal.Cast<sbyte, ulong>(m_ControlBytes.AsSpan(indexBefore..))[0]);  
  
    // 1 グループ分以上の連続した空き領域があるかどうか  
    bool wasNeverFull = emptyBefore != 0 && emptyAfter != 0 &&  
        (TrailingZeroCount(emptyAfter) >> GroupShift) + (LeadingZeroCount(emptyBefore) >> GroupShift) < GroupWidth;  
  
    SetControlByte(index, wasNeverFull ? Empty : Deleted, m_Capacity);  
    m_GrowthLeft += wasNeverFull ? 1 : 0;  
}  

削除時には、まず現在位置とそのひとつ前のグループを取得し、それらの空き領域を確認します。
空き領域が連続して 1 グループ以上ある場合は空き領域として、そうでない場合は削除済み領域として設定します。
なぜこうしているのかというと、空き領域が連続して 1 グループぶんあるということは、どうグループをとっても探索の終了条件を満たす (詳しくは探索の項を参照) ため、それ以外の要素の探索に影響を及ぼさないとわかるためです。逆に、 1 グループ未満の状態で削除済みではなく空き領域にしてしまった場合、既存の他の要素が探索不能になる可能性があります。

ankerl::unordered_dense::map

次に紹介するのは、 Comprehensive C++ Hashmap Benchmarks 2022 で提案されている ankerl::unordered_dense::map です。
Robin Hood Hashing の改良版という感じなのですが、なかなか巧みな実装をして高速化を図っているので紹介します。ところで、リンク先の比較も網羅的で凄いのでぜひ見てみてください。
オリジナルの実装は https://github.com/martinus/unordered_dense を参照してください。

データの持ち方はこんな感じです。

private record struct Metadata(uint Fingerprint, int ValueIndex) {}  
  
private KeyValuePair<TKey, TValue>[] m_Values;  
private Metadata[] m_Metadata;  
  
private int m_Size;  
private int m_Shifts;  

Metadata は名前の通り制御情報で、 Fingerprint と ValueIndex を持ちます。
Fingerprint は上位 3 バイトに「本来あるべき位置からの距離 + 1 (0 なら空き)」、下位 1 バイトに「ハッシュ値の下位 1 バイト」の情報を持ちます。
ValueIndex は対応する m_Values のインデックスです。制御情報を key や value とは分離して持つことによって、 Keys や Values の列挙が (そのまま流すだけなので) 理論上最速となります。

flowchart LR  
  
subgraph Values  
    Key  
    Value  
end  
  
subgraph Metadata  
    subgraph Fingerprint  
        Distance  
        HashLower  
    end  
    ValueIndex  
end  

データを与えるとするとこんな感じです。

block-beta  
columns 1  
  
block  
    columns 4  
    fingerprint1["Fingerprint: 0x00000101"]  
    fingerprint2["Fingerprint: 0x00000103"]  
    fingerprint3["Fingerprint: 0x00000000"]  
    fingerprint4["Fingerprint: 0x00000102"]  
    valueIndex1["ValueIndex: 0"]  
    valueIndex2["ValueIndex: 2"]  
    valueIndex3["ValueIndex: -1"]  
    valueIndex4["ValueIndex: 1"]  
end  
  
block  
    columns 4  
    key1["Key: Alice"]  
    key2["Key: Barbara"]  
    key3["Key: Charlotte"]  
    key4["Key: -"]  
    value1["Value: Alice"]  
    value2["Value: Barbara"]  
    value3["Value: Charlotte"]  
    value4["Value: -"]  
end  
  
valueIndex1 --> key1  
valueIndex2 --> key3  
valueIndex4 --> key2  

Metadata と違って Values は密に詰まっているところがポイントです。

探索 (Get)

// `Fingerprint` の距離バイト 1 つぶん  
private const int DistanceUnit = 0x100;  
  
// hashCode -> Metadata.Fingerprint  
// 距離は 0 とする  
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
private static uint HashCodeToFingerprint(int hashCode)  
{  
    return DistanceUnit | ((uint)hashCode & 0xff);  
}  
  
// 要するに hashCode / m_Values.Length みたいなもの  
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
private static int HashCodeToMetadataIndex(int hashCode, int shift)  
{  
    return (int)((uint)hashCode >> shift);  
}  
  
// (metadataIndex + 1) % m_Metadata.Length の剰余算使わない版  
[MethodImpl(MethodImplOptions.AggressiveInlining)]  
private int IncrementMetadataIndex(int metadataIndex)  
{  
    return metadataIndex < m_Metadata.Length - 1 ? metadataIndex + 1 : 0;  
}  
  
private int GetEntryIndex(TKey key)  
{  
    int hashCode = key.GetHashCode();  
    uint fingerprint = HashCodeToFingerprint(hashCode);  
    int metadataIndex = HashCodeToMetadataIndex(hashCode, m_Shifts);  
  
    var metadata = m_Metadata[metadataIndex];  
  
    while (true)  
    {  
        // fingerprint が一致していれば、キーを比較。合っていればそれを返す  
        if (fingerprint == metadata.Fingerprint)  
        {  
            if (EqualityComparer<TKey>.Default.Equals(m_Values[metadata.ValueIndex].Key, key))  
            {  
                return metadata.ValueIndex;  
            }  
        }  
        else if (fingerprint > metadata.Fingerprint)  
        {  
            // 距離を比較してデータより遠ければ要素がないことが分かる  
            return -1;  
        }  
  
        // 距離を伸ばして探索を続行  
        fingerprint += DistanceUnit;  
        metadataIndex = IncrementMetadataIndex(metadataIndex);  
        metadata = m_Metadata[metadataIndex];  
    }  
}  

Fingerprint にはハッシュ値の下位 1 バイトが含まれているので、これがマッチする時だけ実際の比較を行うことで、間違った比較を実行する確率を単純計算で 1/256 にできます。

else if (fingerprint > metadata.Fingerprint) の部分ですが、ここも工夫が出ているポイントです。
Fingerprint の上位 3 バイトは「理想の位置からの距離 (+1)」が入っているので、この条件を満たすときは、入っているデータのほうが距離が短い = 求めるべきデータはないことが分かります。
距離が異なる場合はハッシュ値は比較に関与しないため、例えばビット演算で切り分けるなどする必要はありません。しかも、距離が同じ場合は枝刈りができます。距離が同じ要素はハッシュ値でソートされた状態になっているので (詳しくは拡張の項で) 、大小比較をすることで探索回数を約半分にすることができます。詳しくは https://qiita.com/nicklegr/items/2108db9e5ea88db00b97 をご参照ください。

追加 (Add)

private bool AddEntry(TKey key, TValue value, bool overwrite)  
{  
    int hashCode = key.GetHashCode();  
    uint fingerprint = HashCodeToFingerprint(hashCode);  
    int metadataIndex = HashCodeToMetadataIndex(hashCode, m_Shifts);  
  
    // 既存要素を探索、存在していれば上書き  
    while (fingerprint <= m_Metadata[metadataIndex].Fingerprint)  
    {  
        if (fingerprint == m_Metadata[metadataIndex].Fingerprint &&  
            EqualityComparer<TKey>.Default.Equals(key, m_Values[m_Metadata[metadataIndex].ValueIndex].Key))  
        {  
            if (overwrite)  
            {  
                m_Values[m_Metadata[metadataIndex].ValueIndex] = new KeyValuePair<TKey, TValue>(key, value);  
                return true;  
            }  
            else  
            {  
                return false;  
            }  
        }  
  
        fingerprint += DistanceUnit;  
        metadataIndex = IncrementMetadataIndex(metadataIndex);  
    }  
  
  
    // 末尾に追加・挿入  
    m_Size++;  
    m_Values[m_Size - 1] = new KeyValuePair<TKey, TValue>(key, value);  
    PlaceAndShiftUp(new Metadata(fingerprint, m_Size - 1), metadataIndex);  
  
  
    // 領域が埋まってきたら拡大 (MaxLoadFactor = 0.8)  
    if (m_Size >= m_Metadata.Length * MaxLoadFactor)  
    {  
        m_Shifts--;  
        int newCapacity = 1 << (32 - m_Shifts);  
        Resize(newCapacity);  
    }  
  
    return true;  
}  
  
// 空き領域が見つかるまで右側にずらしていく  
private void PlaceAndShiftUp(Metadata metadata, int metadataIndex)  
{  
    while (m_Metadata[metadataIndex].Fingerprint != 0)  
    {  
        (metadata, m_Metadata[metadataIndex]) = (m_Metadata[metadataIndex], metadata);  
        metadata.Fingerprint += DistanceUnit;  
        metadataIndex = IncrementMetadataIndex(metadataIndex);  
    }  
    m_Metadata[metadataIndex] = metadata;  
}  

既存要素の探索・上書きはいつものという感じですね。
挿入に関しては、実データは m_Values の末尾に挿入します。こうすることで m_Values は隙間なく要素が並ぶことになり、列挙を単純に行うことができます。
メタデータのほうですが、理想となる位置に挿入したのち、そこに既に要素が存在していた場合は右側にずらしていきます。単に List<T>.Insert() みたいなものと考えるとわかりやすそうですね。

block-beta  
columns 1  
  
block  
    columns 4  
    fingerprint1["Fingerprint: 0x00000101"]  
    fingerprint2["Fingerprint: 0x00000102"]  
    fingerprint3["Fingerprint: 0x00000103"]  
    fingerprint4["Fingerprint: 0x00000000"]  
    valueIndex1["ValueIndex: 0"]  
    valueIndex2["ValueIndex: 1"]  
    valueIndex3["ValueIndex: 2"]  
    valueIndex4["ValueIndex: -1"]  
end  
  
block  
    columns 4  
    key1["Key: Alice"]  
    key2["Key: Barbara"]  
    key3["Key: Charlotte"]  
    key4["Key: -"]  
    value1["Value: Alice"]  
    value2["Value: Barbara"]  
    value3["Value: Charlotte"]  
    value4["Value: -"]  
end  
  
valueIndex1 --> key1  
valueIndex2 --> key2  
valueIndex3 --> key3  

ここに Barbara とハッシュ値のインデックスが重複する Diona を挿入するときのことを考えると、

block-beta  
columns 1  
  
block  
    columns 4  
    fingerprint1["Fingerprint: 0x00000101"]  
    fingerprint2["Fingerprint: 0x00000102"]  
    fingerprint3["Fingerprint: 0x00000202"]  
    fingerprint4["Fingerprint: 0x00000203"]  
    valueIndex1["ValueIndex: 0"]  
    valueIndex2["ValueIndex: 3"]  
    valueIndex3["ValueIndex: 1"]  
    valueIndex4["ValueIndex: 2"]  
end  
  
block  
    columns 4  
    key1["Key: Alice"]  
    key2["Key: Barbara"]  
    key3["Key: Charlotte"]  
    key4["Key: Diona"]  
    value1["Value: Alice"]  
    value2["Value: Barbara"]  
    value3["Value: Charlotte"]  
    value4["Value: Diona"]  
end  
  
valueIndex1 --> key1  
valueIndex2 --> key4  
valueIndex3 --> key2  
valueIndex4 --> key3  

こんな感じになります。 Barbara と Charlotte の Metadata がひとつ右にずれたのが分かるかと思います。

æ‹¡å¼µ (Resize)

private void Resize(int newCapacity)  
{  
    var oldValues = m_Values;  
    var oldMetadata = m_Metadata;  
  
    // 新バッファの確保  
    m_Values = ArrayPool<KeyValuePair<TKey, TValue>>.Shared.Rent(newCapacity);  
    oldValues.AsSpan(..m_Size).CopyTo(m_Values.AsSpan());  
    m_Metadata = ArrayPool<Metadata>.Shared.Rent(newCapacity);  
    m_Metadata.AsSpan().Clear();  
  
    for (int i = 0; i < m_Size; i++)  
    {  
        (uint fingerprint, int metadataIndex) = NextWhileLess(m_Values[i].Key);  
        PlaceAndShiftUp(new Metadata(fingerprint, i), metadataIndex);  
    }  
  
    // 旧バッファの解放  
    ArrayPool<KeyValuePair<TKey, TValue>>.Shared.Return(oldValues, true);  
    ArrayPool<Metadata>.Shared.Return(oldMetadata);  
}  
  
// fingerprint がそれ以上の要素を見つけるまで探索  
private (uint fingerprint, int metadataIndex) NextWhileLess(TKey key)  
{  
    int hashCode = key.GetHashCode();  
    uint fingerprint = HashCodeToFingerprint(hashCode);  
    int metadataIndex = HashCodeToMetadataIndex(hashCode, m_Shifts);  
  
    while (fingerprint < m_Metadata[metadataIndex].Fingerprint)  
    {  
        fingerprint += DistanceUnit;  
        metadataIndex = IncrementMetadataIndex(metadataIndex);  
    }  
  
    return (fingerprint, metadataIndex);  
}  

今までのと同様に、拡張したバッファを確保してそちらに移し替える作業を行います。

NextWhileLess() は、 fingerprint がそれ以上の、つまり「理想との距離が長い」または「理想との距離は同じで、ハッシュ値が大きい」要素を見つけるまで線形探索するメソッドです。
これによって、理想との距離が短い順に、かつハッシュ値が小さい順に並ぶことが保証されます。

削除 (Remove)

private bool RemoveEntry(TKey key)  
{  
    // fingerprint < (key.Fingerprint) の要素を読み飛ばす  
    (uint fingerprint, int metadataIndex) = NextWhileLess(key);  
  
    // マッチする要素があるか探す  
    while (fingerprint == m_Metadata[metadataIndex].Fingerprint &&  
        !EqualityComparer<TKey>.Default.Equals(m_Values[m_Metadata[metadataIndex].ValueIndex].Key, key))  
    {  
        fingerprint += DistanceUnit;  
        metadataIndex = IncrementMetadataIndex(metadataIndex);  
    }  
  
    if (fingerprint != m_Metadata[metadataIndex].Fingerprint)  
    {  
        return false;  
    }  
  
    // 該当するインデックスの要素を削除  
    RemoveAt(metadataIndex);  
    return true;  
}  
  
private void RemoveAt(int metadataIndex)  
{  
    int valueIndex = m_Metadata[metadataIndex].ValueIndex;  
  
    int nextMetadataIndex = IncrementMetadataIndex(metadataIndex);  
    // 理想的な位置にあるか空き要素が見つかるまで、要素を左にずらして詰める  
    while (m_Metadata[nextMetadataIndex].Fingerprint >= DistanceUnit * 2)  
    {  
        m_Metadata[metadataIndex] = new Metadata(m_Metadata[nextMetadataIndex].Fingerprint - DistanceUnit, m_Metadata[nextMetadataIndex].ValueIndex);  
        (metadataIndex, nextMetadataIndex) = (nextMetadataIndex, IncrementMetadataIndex(nextMetadataIndex));  
    }  
  
    // 空き要素にする  
    m_Metadata[metadataIndex] = new Metadata();  
  
  
    // valueIndex が末尾でなければ、末尾と交換  
    if (valueIndex != m_Size - 1)  
    {  
        m_Values[valueIndex] = m_Values[m_Size - 1];  
  
        int movingHashCode = m_Values[valueIndex].Key.GetHashCode();  
        int movingMetadataIndex = HashCodeToMetadataIndex(movingHashCode, m_Shifts);  
  
        int valueIndexBack = m_Size - 1;  
        while (valueIndexBack != m_Metadata[movingMetadataIndex].ValueIndex)  
        {  
            movingMetadataIndex = IncrementMetadataIndex(movingMetadataIndex);  
        }  
        m_Metadata[movingMetadataIndex].ValueIndex = valueIndex;  
    }  
  
    m_Size--;  
}  

RemoveAt の最初にある while (m_Metadata[nextMetadataIndex].Fingerprint >= DistanceUnit * 2) は、距離 1 以上の要素がある間、という意味ですが、逆に言えば、距離 0 (つまり、理想的な位置にある) または空き要素があれば停止、ということです。逆の表現のほうが分かりやすそう。

if (valueIndex != m_Size - 1) から始まるゾーンは、要するに m_Values の末尾の要素と削除対象の位置を交換しています。ごちゃごちゃやっているのはメタデータの ValueIndex の整合性を保つためですね。
m_Size--; するので、末尾の要素 (交換する前に削除すべき要素だったもの) は実質削除されることになります。 List とかと違って順序を気にしなくてよいコレクションなのでできる技ですね。

block-beta  
columns 1  
  
block  
    columns 4  
    fingerprint1["Fingerprint: 0x00000101"]  
    fingerprint2["Fingerprint: 0x00000102"]  
    fingerprint3["Fingerprint: 0x00000202"]  
    fingerprint4["Fingerprint: 0x00000203"]  
    valueIndex1["ValueIndex: 0"]  
    valueIndex2["ValueIndex: 3"]  
    valueIndex3["ValueIndex: 1"]  
    valueIndex4["ValueIndex: 2"]  
end  
  
block  
    columns 4  
    key1["Key: Alice"]  
    key2["Key: Barbara"]  
    key3["Key: Charlotte"]  
    key4["Key: Diona"]  
    value1["Value: Alice"]  
    value2["Value: Barbara"]  
    value3["Value: Charlotte"]  
    value4["Value: Diona"]  
end  
  
valueIndex1 --> key1  
valueIndex2 --> key4  
valueIndex3 --> key2  
valueIndex4 --> key3  

実例として、ここから Barbara を削除してみましょう。
まずはメタデータ側から Barbara の分を削除して、左に詰めます。

block-beta  
columns 1  
  
block  
    columns 4  
    fingerprint1["Fingerprint: 0x00000101"]  
    fingerprint2["Fingerprint: 0x00000102"]  
    fingerprint3["Fingerprint: 0x00000103"]  
    fingerprint4["Fingerprint: 0x00000000"]  
    valueIndex1["ValueIndex: 0"]  
    valueIndex2["ValueIndex: 3"]  
    valueIndex3["ValueIndex: 2"]  
    valueIndex4["ValueIndex: -1"]  
end  
  
block  
    columns 4  
    key1["Key: Alice"]  
    key2["Key: Barbara"]  
    key3["Key: Charlotte"]  
    key4["Key: Diona"]  
    value1["Value: Alice"]  
    value2["Value: Barbara"]  
    value3["Value: Charlotte"]  
    value4["Value: Diona"]  
end  
  
valueIndex1 --> key1  
valueIndex2 --> key4  
valueIndex3 --> key3  

そのあとで、 key-value 側の末尾要素である Diona と Barbara を交換します。

block-beta  
columns 1  
  
block  
    columns 4  
    fingerprint1["Fingerprint: 0x00000101"]  
    fingerprint2["Fingerprint: 0x00000102"]  
    fingerprint3["Fingerprint: 0x00000103"]  
    fingerprint4["Fingerprint: 0x00000000"]  
    valueIndex1["ValueIndex: 0"]  
    valueIndex2["ValueIndex: 1"]  
    valueIndex3["ValueIndex: 2"]  
    valueIndex4["ValueIndex: -1"]  
end  
  
block  
    columns 4  
    key1["Key: Alice"]  
    key2["Key: Diona"]  
    key3["Key: Charlotte"]  
    key4["Key: Barbara"]  
    value1["Value: Alice"]  
    value2["Value: Diona"]  
    value3["Value: Charlotte"]  
    value4["Value: Barbara"]  
end  
  
valueIndex1 --> key1  
valueIndex2 --> key2  
valueIndex3 --> key3  

これで削除処理は完了です。 key-value 側に残っているデータは参照されず、次に追加されるときに上書きされるので問題ありません。

ハッシュテーブルを用いない方式について

本稿では詳しく取り上げませんが、ハッシュテーブルを用いない方式も存在します。
例えば、木構造 (trie など) を用いる方式があります。具体的かつ身近な例では、 ImmutableDictionary は AVL 木 で 実装されている そうです。
ほかにも、 HAMT (Hash Array Mapped Trie) やそれを JVM 用に改良した CHAMP (Compressed Hash-Array Mapped Prefix-tree) *4 といったアルゴリズムが存在します。

木構造を利用した実装は、要素を使いまわせるため ImmutableDictionary のようなタイプと相性がいいようで、結構利用例を見かけます。ただし性能特性はハッシュテーブル型と異なっており、基本的に探索・追加・削除ともに O(\log n) となる (参照) ため注意が必要です。まぁ実際に測定してみるのがよいでしょう。

パフォーマンス比較

さて、一番気になるところである、パフォーマンスの比較を行います。
ベンチマーク環境は以下の通りです。

BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3593/23H2/2023Update/SunValley3)  
12th Gen Intel Core i7-12700F, 1 CPU, 20 logical and 12 physical cores  
.NET SDK 8.0.200  
  [Host]     : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2  
  DefaultJob : .NET 8.0.2 (8.0.224.6711), X64 RyuJIT AVX2  

今回は、以下の項目についてベンチマークをとりました。

  • Copy: 1024 個の要素を追加
  • AddAndRemove: 1024 個の要素を追加し、その後順次削除
  • Iterate: 1024 個の要素の列挙
  • Find: 1024 個の要素が入った状態でランダムなキーを探索
    • キーが存在しない確率が 0% / 25% / 50% / 75% / 100% のそれぞれについて
  • Alloc: Copy 実行時のメモリアロケーション

また、 TKey には ulong (構造体) と string (クラス) を用いました。

結果はこのようになりました。数値は処理にかかった時間 (μs) または消費メモリ (Byte) です。
Score は、その列で最速の要素を 1 に正規化したときの値について幾何平均をとったもので、要するに小さいほど速いです。 Dictionary は比較用の System.Collections.Generic.Dictionary です。

Algorithm AddAndRemoveNumber CopyNumber FindNumber0 FindNumber25 FindNumber50 FindNumber75 FindNumber100 IterateNumber AddAndRemoveString CopyString FindString0 FindString25 FindString50 FindString75 FindString100 IterateString AllocNumber AllocString Score
Ankerl 19.4206 13.2557 6.2936 10.0994 9.1557 6.6179 4.8774 0.8199 71.3392 41.365 28.3604 28.0034 22.5363 18.2278 41.5786 1.1206 49273 49273 1.26
SeparateChaining 12.7759 9.1988 10.3843 12.2133 13.0501 11.6838 13.4845 0.9335 52.3848 37.0008 30.369 31.8386 31.413 29.3441 52.0027 1.2142 24760 24761 1.36
Hopscotch 37.215 24.155 4.276 7.08 9.679 7.263 5.142 2.246 96.031 62.795 23.679 23.149 21.747 19.211 43.28 3.03 36993 36993 1.45
OpenAddressing 12.459 9.181 13.519 15.968 16.349 16.185 16.399 1.084 50.491 32.039 29.04 30.053 32.505 31.33 56.524 1.168 24664 49241 1.52
Dictionary 19.819 12.655 12.44 14.368 15.361 14.862 18.608 2.311 75.061 52.227 29.714 29.979 31.31 29.714 57.577 2.535 49241 49241 1.86
SwissTable 33.907 19.718 7.131 10.519 14.282 13.719 12.086 5.762 57.359 33.831 23.816 23.908 23.061 20.369 41.937 7.825 102274 102274 1.95
RobinHood 29.128 29.326 12.604 20.919 28.59 35.229 38.195 1.134 51.215 41.919 27.552 33.895 39.038 42.918 77.689 1.291 24664 49241 2.09

スコアをグラフにするとこんな感じです。青い面がトータルのスコアで、低いほうが良いです。


スコアより、今回試した中では Ankerl が全体的に速いことが分かりました。

個別に見ていきましょう。

Ankerl

Ankerl は全体的に高速だったのですが、特に探索が高速で、中でもキーが存在しない確率が 50% 以上のときが最速でした。
存在しないキーを引こうとしたときは低速になりがちなので、そういう状況でも速度が落ちないのは強みですね。
加えて、列挙も最速です。そうなるように設計されていたのでこれは予想通りの結果でした。
あえて欠点を挙げるとすれば、追加、特に削除は比較的低速です。

SeparateChaining

SeparateChaining は予想よりもかなり高速でした。
特に追加と削除がほぼ最速でした。シンプルゆえの強さでしょうか。
また、消費メモリ量も少ない利点があります。
対して、探索は比較的低速で、特に存在しないキーを引こうとしたときに遅くなりがちです。

Hopscotch

Hopscotch は、存在するキーの探索が最速でした。ビットマップの強みが出ていましたね。
存在しないキーの探索でもあまり性能が落ちません。
対して、追加と削除・列挙は結構低速です。

OpenAddressing

OpenAddressing は、追加と削除が最速でした。これもシンプルゆえの強さでしょうね。
列挙はまずまずといったところです。
対して、探索は全体的に遅めです。存在しないキーを探索する場合に特に遅くなる傾向があります。

Dictionary

内部実装は基本的に SeparateChaining と同じ方式ですが、標準ライブラリゆえのしがらみのせいか全体的に遅くなっている気がします。
傾向としては SeparateChaining と同様、追加と削除が比較的高速、探索が比較的低速です。

SwissTable

もうちょっと速くなると思っていたのでこの順位は意外でした。私の実装が悪かったのかもしれない……
あと今回 SIMD を使っていないので本領を発揮できていないという点に注意が必要です。
特に string (クラス) の探索が高速で、存在しないキーに対しても性能が落ちません。
対して、なぜか ulong (構造体) では全体的に遅くなっています。
また、列挙は非常に低速です。
また、メモリ消費量も多い結果になりました。本来のフットプリントは 1 byte/要素 と少ないはずなのですが、 2 冪よりちょっと大きい領域を確保する必要がある点で肥大化したのかもしれません。

RobinHood

これももうちょっと速くなると思っていたので意外でした。
特に存在しないキーを探索しようとしたときに遅い傾向にあるようです。 ulong では最速の Ankerl の 7.8 倍ほどの時間がかかっています。
また、追加と削除、特に追加が遅い傾向があるようです。
なお、列挙は比較的早いようです。

さらなる高速化

アルゴリズム面ではなく、小手先でちょっと高速化できるか考えてみましょう。

GetEnumerator()

Dictionary では、 GetEnumerator() を書くところがありますが、このようにすると簡単……ですが、よくありません。

public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()  
{  
    foreach(var pair in m_Entries)  
    {  
        yield return pair;  
    }  
}  

yield return を使うと、内部でクラスが生成されるためアロケーションが入ります。 (参考)
そのため、地道に手書きで実装していくことになります。
一例を挙げると、こんな感じです。

IEnumerator<KeyValuePair<TKey, TValue>> IEnumerable<KeyValuePair<TKey, TValue>>.GetEnumerator()  
    => GetEnumerator();  
      
IEnumerator IEnumerable.GetEnumerator()  
    => GetEnumerator();  
  
IDictionaryEnumerator IDictionary.GetEnumerator() => GetEnumerator();  
  
public Enumerator GetEnumerator()  
    => return new Enumerator(this);  
  
public struct Enumerator : IEnumerator<KeyValuePair<TKey, TValue>>, IDictionaryEnumerator  
{  
    private readonly AnkerlDictionaryMod<TKey, TValue> m_Parent;  
    private int m_Index;  
    private int m_Version;  
  
    internal Enumerator(AnkerlDictionaryMod<TKey, TValue> parent)  
    {  
        m_Parent = parent;  
        m_Index = -1;  
        m_Version = m_Parent.m_Version;  
    }  
  
    public KeyValuePair<TKey, TValue> Current => m_Parent.m_Values[m_Index];  
  
    public DictionaryEntry Entry => new DictionaryEntry(Current.Key, Current.Value);  
  
    public object Key => Current.Key;  
  
    public object? Value => Current.Value;  
  
    object IEnumerator.Current => Current;  
  
    public void Dispose()  
    {  
    }  
  
    public bool MoveNext()  
    {  
        if (m_Parent.m_Version != m_Version)  
            throw new InvalidOperationException("Collection was modified; enumeration operation may not execute.");  
  
        return ++m_Index < m_Parent.m_Size;  
    }  
  
    public void Reset() => throw new NotSupportedException();  
}  

まずは自前実装した構造体を返す GetEnumerator() を書いたうえで、 Enumerator 構造体を地道に書いていきます。

Dictionary には本体の列挙以外にも Keys ・ Values もあるため、 3 つも書かなければなりません。しんどい!

ちなみに、この手法に関しては上記ベンチマークの時点で導入済みでした。

Devirtualization (値型の場合の EqualityComparer<TKey>.Default の利用)

公式の Dictionary の コードを見てみる と、こういった趣旨のコメントがあります。

参照型の場合は、都度 EqualityComparer<TKey>.Default にアクセスするとオーバーヘッドが生じるため、常に Comparer を保持するようにします。
値型の場合、 Comparer が指定されなかった場合は、毎回 EqualityComparer<TKey>.Default を使用することで、 JIT で Devirtualize 可能にして命令をインライン化できるようにします。

雑に言えば、値型かつ IEqualityCompaerer<TKey> が指定されなかった (デフォルトのを利用する) 場合は、あえてキャッシュせずに EqualityComparer<TKey>.Default を直接使ったほうが速くなるそうです。

実際にアセンブリを見てみましょう。

using System;  
using System.Collections.Generic;  
public class C {  
    private IEqualityComparer<ulong> comparer = EqualityComparer<ulong>.Default;  
      
    public bool M1(ulong value) {  
        return comparer.Equals(value, 123);  
    }  
      
    public bool M2(ulong value) {  
        return EqualityComparer<ulong>.Default.Equals(value, 123);  
    }  
}  

sharplab を使って JIT Asm を見てみると、

; Core CLR 8.0.123.58001 on x64  
  
C..ctor()  
    L0000: mov rdx, 0x214cd001d80  
    L000a: mov rdx, [rdx]  
    L000d: lea rcx, [rcx+8]  
    L0011: call 0x00007ff8c1bc0010  
    L0016: nop  
    L0017: ret  
  
C.M1(UInt64)  
    L0000: mov rcx, [rcx+8]  
    L0004: mov r11, 0x7ff8cfa9a000  
    L000e: mov r8d, 0x7b  
    L0014: cmp [rcx], ecx  
    L0016: jmp qword ptr [r11]  
  
C.M2(UInt64)  
    L0000: xor eax, eax  
    L0002: cmp rdx, 0x7b  
    L0006: sete al  
    L0009: ret  

確かに命令数が減ってシンプルになっていることが確認できます。

配列の範囲チェック省略

さて、今までしょっちゅう m_Entries[index] とかしてきたわけなのですが、暗黙に配列の範囲チェック (0 <= index && index < m_Entries.Length のような) が行われており、条件に反した場合に例外が飛ぶようになっています。それはそう。
しかし、この index が配列範囲内にあることが分かり切っている場合 (事前に index = hoge % m_Entries.Length しているなど) 、このチェックは無駄に時間がかかるだけです。
特に Dictionary の実装では、ハッシュコードを配列の範囲内に移すコードが頻繁に存在するので、効果的かもしれません。

というわけで、配列の範囲チェックを外してみましょう。 Unsafe 黒魔術の出番です。

[MethodImpl(MethodImplOptions.AggressiveInlining)]  
private static ref T At<T>(T[] array, int index)  
{  
#if !DEBUG  
    return ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(array), index);  
#else  
    return ref array[index];  
#endif  
}  

こういうメソッドを作ってみました。
MemoryMarshal.GetArrayDataReference() は、 array[0] への参照を返すメソッドです。 C で言うところの &(array[0]) みたいなものです。
そして Unsafe.Add() は、参照に所定のオフセットを加算した位置の参照を返します。要するに ptr + offset ですね。
これらを合わせることで、 &(array[0]) + offset 、つまり array[offset] を参照できるというわけです。この時、範囲チェックは行われません。

実際に sharplab で確認 してみましょう。

using System;  
using System.Runtime.CompilerServices;  
public class C {  
      
    private static ref T At<T>(T[] array, int index)  
    {  
        return ref Unsafe.Add(ref System.Runtime.InteropServices.MemoryMarshal.GetArrayDataReference(array), index);  
    }  
      
    private int[] Hoge = new int[256];  
      
    public int M1() {  
        return Hoge[123];  
    }  
      
    public int M2() {  
        return At(Hoge, 123);  
    }  
}  
; Core CLR 8.0.123.58001 on x64  
  
(中略)  
  
C.M1()  
    L0000: sub rsp, 0x28  
    L0004: mov rax, [rcx+8]  
    L0008: cmp dword ptr [rax+8], 0x7b  ; 境界値チェック  
    L000c: jbe short L0019  
    L000e: mov eax, [rax+0x1fc]  
    L0014: add rsp, 0x28  
    L0018: ret  
    L0019: call 0x00000218236400a8  ; 例外投げ  
    L001e: int3  
  
C.M2()  
    L0000: mov rax, [rcx+8]  
    L0004: cmp [rax], al  
    L0006: mov eax, [rax+0x1fc]  
    L000c: ret  

というわけで高速化が見込めるのですが、うっかり配列の範囲外を参照したときに IndexOutOfRangeException は飛ばなくなり、不定の値を参照することになります。悪名高い C 言語と同じですね、コワイ!
得られるものに対してリスクが大きいので、十分なデバッグのもとで実装するか、そもそも実装しないでください。最終手段みたいなものです。

sealed にする

継承させる意味もないので、 sealed にしておきましょう。
仮想メソッド呼び出しの IL 命令が callvirt から call になって 多少の高速化が見込める ……らしいです。

結果

高速化は結果を見ないと始まりませんね。ベンチマークを取ってみました。

Algorithm AddAndRemoveNumber CopyNumber FindNumber0 FindNumber25 FindNumber50 FindNumber75 FindNumber100 IterateNumber AddAndRemoveString CopyString FindString0 FindString25 FindString50 FindString75 FindString100 IterateString AllocNumber AllocString Score
AnkerlMod 20.3139 12.6789 6.32 9.9947 8.8141 6.2413 4.5693 0.8164 75.2641 42.0835 29.1358 28.2004 22.9037 19.2394 41.449 1.1197 49273 49273 1.26
Ankerl 19.4206 13.2557 6.2936 10.0994 9.1557 6.6179 4.8774 0.8199 71.3392 41.365 28.3604 28.0034 22.5363 18.2278 41.5786 1.1206 49273 49273 1.26

……誤差だよ誤差!

余談:バージョンについて

ここでの「バージョン」は列挙中の変更による未定義動作の回避策を指します。
例えば、以下のようなコードで列挙中に値を追加・削除しようとすると、

var dict = new Dictionary<int, int>() { { 1, 2 }, { 3, 4 } };  
foreach (var elem in dict)  
{  
    dict.Add(5, 6);  
}  

こういう例外が飛びます。

例外が発生しました: CLR/System.InvalidOperationException  
型 'System.InvalidOperationException' のハンドルされていない例外が System.Private.CoreLib.dll で発生しました: 'Collection was modified; enumeration operation may not execute.'  

この例外が出る理由としては、列挙中に追加・削除などコレクションの順番が変わるような処理が走った場合に、「2 回列挙される」「列挙されない」といった意図しない挙動が発生するためです。 (自分で for 文を書いた場合のことを考えれば、 i が指す位置が変わってしまえば列挙に障害が出ることは分かるかと思います。)
エラーが発生しない未定義動作ほど怖いものはないというのは皆さんご存知の通りでしょう。鼻から悪魔が!

で、これをどうやって検知しているのかを説明しましょう。
まず、コレクション側に version 変数を持っておきます。これ自身はただの int 型のフィールドです。
そして、追加や削除などの操作を行うたびに version 変数をインクリメントします。
対して列挙する側では、 GetEnumerator() で列挙子を作成する際に、 version フィールドのコピーを持たせておきます。
あとは列挙中 (MoveNext() が呼ばれたとき) に元のコレクションの version と作成時の version を比較して、違っていれば例外を投げる、といった実装です。

ちなみにですが、 version をインクリメントすることで変更検知している以上、列挙中に 2^{32} 回の変更を加えるとオーバーフローして戻ってくるのでバージョンチェックを回避することができます。まぁそんなことをする人はいないとは思いますが……

余談: GetHashCode() の実装について

Dictionary の実装では文字通りキーとなっていた GetHashCode() ですが、これを実装する段になったら結構困った、という方は多いかもしれません。特に Equals() をオーバーライドしたはいいけどこれはどうするんだ、となった経験がある方は多いでしょう。実際どういった実装をすれば「良い」のでしょうか?

.NET Standard 2.1 (.NET Core 2.1) 以降であれば、 HashCode 構造体を使うのがベストでしょう。値を順次 Add するなり、 Combine するなりすればいい感じのハッシュ値を返してくれます。

それ以前であれば…… 自力で xxHash や MurmurHash を実装するとか、手抜きで全メンバの GetHashCode() を xor したものを返すとか…… あまりよくはないのですが。

逆に、「悪い」実装について考えてみましょう。

まず、「ランダムな値を返す」とかは論外です。 GetHashCode() のコントラクト を守ってください。

次に、「オブジェクトの内容にかかわらず同じ値を返す」です。
GetHashCode() => 0; とかされると目も当てられないことになります。
これは一応 GetHashCode() のコントラクト である「等価なオブジェクトなら同じ値を返す」は満たしてはいるのですが、各種アルゴリズムの効率が最悪になります。 O(n) もざらというか、線形探索のほうがワンチャン速い可能性さえありますし、先に述べた Hopscotch Hashing に至っては爆発します。

あとは、「変更可能なフィールドやプロパティに依存する」です。 Unity の Vector3 構造体 とか。
何が問題になるのかというと、キーを登録した後にその GetHashCode() の値が変わると、当然探索不能になります。
なので、そういう場合は変更不能なフィールドやプロパティのみ GetHashCode() の計算に用いるとか、そうでない (自分で手を入れることができない) 場合は変更しないように気を付ける、といったところです。そもそも変更可能な構造体を作るな、はそれはそう。

また、「取りうる値域が狭い」場合にも注意が必要です。例えば someValue % 13 のような。
値域が狭いと同一ハッシュ値を引きやすく、衝突が激しく起こってパフォーマンスが低下します。
ここで注意したいのが int や ulong といったプリミティブ整数型です。
int.GetHashCode() は value そのもの、 ulong.GetHashCode() は value ^ value << 32 で実装されています。したがって、 int などで連番を与えた場合、特定の範囲に値が偏ってしまうことになります。
さらに言えば、内部実装でハッシュ値をインデックスに変換するとき hashCode >> someShift のような上位ビットだけを利用するコードがあった場合 (Ankerl や SwissTable など) 、さらに情報量が減って衝突が非常に起こりやすくなります。
対策としては、 IEqualityComparer<T> を使って GetHashCode() の挙動を上書きするか、 Dictionary の内部実装に手を入れるなどして hashCode * 747796405 のように適当な奇数を掛けて分散を図る、といったものがあります。

奇数定数の掛け算 (2a+1)x \pmod{2^{n}} は全単射となるため、ハッシュの情報を失わずに分散を良くすることができます。

余談: IEqualityComparer<T> について

IEqualityComparer<T> を Dictionary のコンストラクタで渡すと、 Equals() や GetHashCode() の挙動を外部から変更することができます。
それで何が便利かというと、 string なら大文字小文字を区別しない Dictionary を作ったり、より高速なハッシュコード実装に入れ替えたりすることができます。

とくにこだわりがない場合は EqualityComparer<T>.Default を使うと、普通の (オブジェクト側の Equals() や GetHashCode() を呼び出す) 挙動になります。

余談: 読み取り専用コレクションについて

C# には今のところ、 3 つの読み取り専用の Dictionary があります:

それぞれの違いを説明できるでしょうか?私はできませんでした。というか FrozenDictionary って何ですか?

ReadonlyDictionary

.NET Framework 4.5 からある、歴史ある (?) Dictionary です。
.AsReadOnly() で作成することができます。

これを簡単に説明するなら、ただの読み取り専用のビューです。あとから元のコレクションを変更しても反映されます。
具体例を挙げると、以下のコードでは readonlyDic を作成してから元の dic に要素を追加していますが、その変更もきちんと反映されています。

var dic = new Dictionary<string, int> { { "Alice", 16 }, { "Barbara", 17 } };  
var readonlyDic = dic.AsReadOnly();  
  
dic.Add("Charlotte", 19);  
  
foreach (var pair in readonlyDic)  
{  
    Console.WriteLine($"{pair.Key}: {pair.Value}");  
}  
  
// result:  
// Alice: 16  
// Barbara: 17  
// Charlotte: 19  

ImmutableDictionary

これは .NET Core 1.0 から追加された Dictionary です。
.ToImmutableDictionary() で作成することができます。

まずは ReadOnlyDictionary と同じようなコードを書いて実行してみましょう。

var dic = new Dictionary<string, int> { { "Alice", 16 }, { "Barbara", 17 } };  
var immutableDic = dic.ToImmutableDictionary();  
  
dic.Add("Charlotte", 19);  
  
foreach (var pair in immutableDic)  
{  
    Console.WriteLine($"{pair.Key}: {pair.Value}");  
}  
  
// result:  
// Barbara: 17  
// Alice: 16  

結果を見ると、コレクション作成後の変更は反映されていませんね。
雑に言えば、作成した時点での状態を保持します。

また、 Immutable とは言ったものの Add や Remove などを行うこともできます。
ポイントは、こういうメソッドを実行しても元のコレクションは一切変更されないというところです。 Add や Remove メソッドの戻り値は ImmutableDictionary<TKey, TValue> であり、変更を適用したコレクションのコピーが返されます。

いったん作成したコレクションは変更されない性質上、 ImuutableDictionary はスレッドセーフです。

なお、前述したように、 ImmutableDictionary は AVL 木を用いて実装されているそうです。そのため、性能特性が Dictionary とは異なることに注意が必要です。基本的にはコピーが比較的高速で、それ以外の探索・追加・削除は比較的低速になります。

FrozenDictionary

これは .NET 8 から追加された新しい Dictionary です。
新参ではありますが、一般的に「読み取り専用のコレクション」といったときに、一番しっくりくるのはこれだと思います。
これは ToFrozenDictionary() で作成する際に一気にすべての要素を追加したうえで、読み取り専用に最適化する Dictionary です。アプリのマスターデータなど、そういった運用をするケースは多いかと思います。
同じ読み取り専用の ImmutableDictionary は、実装上そんなに高速ではないなど難がありました。アルゴリズム的にも読み取りが O(\log n) で、実測値でも Dictionary の 10~20 倍遅いです (参考)。そこでこの FrozenDictionary が追加されたというわけです。

読み取り専用に最適化がされているため、作成にちょっと時間がかかるかわりに読み取りが Dictionary の 1.5 倍程度速く、また特別な場合においてはさらにさらに高速になるそうです。

内部実装は結構トリッキーというか力技というか、執筆時点では以下のパターンでそれぞれ得られる FrozenDictionary の内部実装が異なります。

  • 要素数が 10 以下で、 EqualityComparer が Default で、 TKey が一部の組み込み値型
  • 要素数が 10 以下で、 EqualityComparer が Default で、 TKey がそれ以外の値型
  • EqualityComparer が Default で、 TKey が int
  • EqualityComparer が Default で、 Tkey が値型
  • TKey が string で、 EqualityComparer が Default か Ordinal か OrdinalIgnoreCase で
    • 長さがバラバラな時
    • Ascii 文字だけの時
    • etc...
  • 要素数が 4 以下で TKey が参照型
  • それ以外 (要素数が多くて TKey が参照型)

それぞれ特殊化された実装を返すことで高速化を図っているようです。
例えば、要素数が少ない場合はハッシュテーブルを用いずに線形探索で済ませる、文字列の長さが全部違うなら中身を見ずに長さだけで判定する、など……

パフォーマンス比較

ここでもベンチマークを取ってみましょう。

  • Copy: 1024 個の要素を含む Dictionary からインスタンスを作成
  • Iterate: 1024 個の要素の列挙
  • Find: 1024 個の要素が入った状態でランダムなキーを探索
    • キーが存在しない確率が 0% / 25% / 50% / 75% / 100% のそれぞれについて
  • Alloc: Copy 実行時のメモリアロケーション

例によって単位は μs, byte です。

Algorithm CopyNumber FindNumber0 FindNumber25 FindNumber50 FindNumber75 FindNumber100 IterateNumber CopyString FindString0 FindString25 FindString50 FindString75 FindString100 IterateString AllocNumber AllocString
ReadOnly 0.006437 3.572898 6.589577 9.761956 14.449337 11.00339 5.870965 0.008972 45.232182 35.366125 24.72045 22.207743 39.204077 8.113581 40 40
Immutable 130.44 43.01 36.76 37.05 22.48 20.57 48.47 329.42 76.45 90.17 89.15 83.78 99.38 29.57 65632 65632
Frozen 16.768 2.771 6.017 8.594 5.58 3.219 1.041 208.801 20.023 21.362 19.68 14.752 26.718 1.307 77224 99472

作成時間に大きな差が表れていますね。
コピーを作成する必要がある ImmutableDictionary / FrozenDictionary に比べて、単なるビューである ReadOnlyDictionary は非常に高速です。ディープコピーとシャローコピーみたいなものです。
また、当然ながら消費メモリも最小です。もとの Dictionary が持っている分が隠れているとも言います。

ImmutableDictionary は全体的に遅くて重いですね。それはそう。
全体的に ReadOnlyDictionary の数倍遅いです。あと列挙が最悪。

FrozenDictionary は、 ImmutableDictionary に比べて作成が高速なほか、前評判通り探索が ReadOnlyDictionary (の背後にある Dictionary) より高速です。
ただ、メモリ消費量は結構多めですね。トレードオフ……

どれを使えばいいのか

ReadOnlyDictionary は、変更可能なコレクションのビューを提供する用途で便利です。
外部と接続するエリアとか、変更はさせたくないけど参照は許可するような場合に便利でしょう。
生成コストも無視できるレベルなので、都度生成するような運用でも問題はないでしょう。

ImmutableDictionary は……、何に使うとよいのでしょうね?
木構造を採用している関係上、要素を共有するテーブル (部分的に異なるテーブル) が複数必要な場合においては、役に立つかもしれません。

FrozenDictionary は、アプリのマスターデータのような、最初に取得して以降不変なデータを持たせるのに最適でしょう。読み取りが Dictionary より高速にできることが期待されます。

余談:シリアライゼーション

Dictionary を json にして保存するとか、シリアライズしたいこともあるでしょう。ここで注意が必要な点を紹介します。
GetHashCode() は必ずしも一貫して同じ値を返すわけではないという点です。 MSDN を見ると (翻訳しました)、

GetHashCode() オブジェクトのメソッドは、オブジェクトの System.Object.Equals メソッドの戻り値を決定するオブジェクトの状態に変更がない限り、同じハッシュコードを一貫して返す必要があります。
これは、アプリケーションの現在の実行に対してのみ当てはまります。再度アプリケーションを実行した場合は、別のハッシュコードを返す場合があります。

というわけで、実行ごとに別々のハッシュコードを発行する実装は許可されています。
より具体的に、 GetHashCode() を実装する際のヘルパーとなる HashCode.Combine() の実行結果を見てみてください。

using System;  
Console.WriteLine($"{HashCode.Combine(123):x8}");  

このコードは、実行するたびにランダムな 8 桁の 16 進数値を表示します。 *5 なんてこった!
HashCode.Combine() の実装を 覗いてみる と、 MixEmptyState() から計算に含まれる s_seed が Interop.GetRandomBytes() 、つまり乱数で初期化されていることが分かります。

これに依存している構造体やクラス、例えば System.Numerics.Vector3 も、もちろん実行ごとにランダムなハッシュ値を返します。

どうしてわざわざ乱数を入れるのかというと、 Dictionary に対する HashDoS 攻撃を回避するためです。 (参照)
HashDoS 攻撃は、同一ハッシュ値となる項目を大量に生成させることで Dictionary (やそれに類する GetHashCode() 実装を根幹に持つもの) の動作を遅くする攻撃です。 実例などは下記ブログが詳しいです。

乱数が含まれず、オブジェクト単位で決定的にハッシュ値が定まる場合、事前に同一ハッシュ値となる項目を多数用意しておいて一気に投げつけるだけで攻撃が成立してしまいます。この記事をここまで読んでこられた方なら (ありがとうございます) 、ハッシュ値が同一だったときの処理にどれだけコストをかけていたか、場合によってはどう爆発するかがお判りになることでしょう。
乱数を計算に含むことによって、このような攻撃を不可能とは言わないまでも困難にすることができます。乱数計算自体は起動ごとに 1 回だけなのでコストにはなりません。

さて、本題に戻ります。ハッシュ値が実行ごとにランダムになるということは、実行を跨いで GetHashCode() の結果を保持してはいけない、つまりシリアライズしてはならないということが分かります。
もし GetHashCode() を直接保存していないタイプのハッシュテーブルだったとしても、バケツの位置が変わるなどするため、シリアライズするときはバイナリをそのまま書き出してはいけません。例えば List<KeyValuePair<TKey, TValue>> のようなレイアウトにして、デシリアライズ時には Resize() のような再配置処理をかける必要があります。

ちなみに、 MemoryPack 用にシリアライズする場合は、 [MemoryPackable(GenerateType.Collection)] と定義するだけでいい感じにしてくれるようです。挙動的には引数なしコンストラクタで初期化した後、各要素を void Add(TKey key, TValue value) 経由で追加してくれる動作になるようです。

またこれは別件ですが、 IEqualityComparer<TKey> m_Comparer も問題となる可能性があります。 IEqualityComparer<TKey> は一般にシリアライズ可能ではないため、情報が失われます。 EqualityComparer<TKey>.Default を使っている分には問題ないかもですが、 StringComparer.OrdinalIgnoreCase などを利用している場合は一工夫が要りそうです。

おわりに

長くなりましたが、 Dictionary がどうやって動いているのか、どんなアルゴリズムがあるのかなどを紹介しました。
Dictionary のことを知る一助となっていれば幸いです。

最後に、本記事で使用したベンチマークなどのコードを載せておきます。

https://github.com/andanteyk/ArrayPoolDictionary

もし誤りなどあればご指摘いただけると助かります。

*1:Herlihy, M., Shavit, N., & Tzafrir, M. (2008). Hopscotch hashing. In Distributed Computing: 22nd International Symposium, DISC 2008, Arcachon, France, September 22-24, 2008. Proceedings 22 (pp. 350-364). Springer Berlin Heidelberg.

*2:Celis, P., Larson, P. A., & Munro, J. I. (1985, October). Robin hood hashing. In 26th annual symposium on foundations of computer science (sfcs 1985) (pp. 281-288). IEEE.

*3:https://math.stackexchange.com/questions/3491464/

*4:Steindorfer, M. J., & Vinju, J. J. (2015, October). Optimizing hash-array mapped tries for fast and lean immutable JVM collections. In Proceedings of the 2015 ACM SIGPLAN International Conference on Object-Oriented Programming, Systems, Languages, and Applications (pp. 783-800).

*5:Sharplab ではまれに同一の値を表示する (インスタンスを使いまわしている?) ことがあるので、手元で実行するのが確実です。

TextMeshPro の隠しタグ一覧

はじめに

TextMeshPro には、 ヘルプ に載っていないタグやパラメータが一部存在します。
それらを紹介していきたいと思います。

本稿の内容は TextMeshPro 3.0.6 で確認しています。

ちなみに、タグの長さの上限は 128 文字です。 <link> タグなどで長い URL を直指定した場合問題になることがあります。

一覧

<br>

改行します。 \u000a と同じです。

<nbsp>

ノーブレークスペースを入れます。 \u00a0 と同じです。

<zwsp>

ゼロ幅スペースを入れます。 \u200b と同じです。

<mark>

color および padding パラメータが使用可能です。

color は略記法 (<mark=#ff000088>) が公式ヘルプには載っていますが、明示的に指定することもできます。

padding は読んで字のごとくパディングを設定します。
<mark padding=左,右,上,下> のように指定します。負の値も指定できるようです。

<material>

マテリアルを指定します。 <material="..."> のように指定します。
実質 <font> の material パラメータでできることと同じな気がします。

</a>

パースはされますが、実装されていません。
閉じタグだけパースされます。

<gradient>

tint パラメータが使用できます。 0 か 1 を指定するようです。
<gradient tint=1> のように指定します。詳しくは試してみてください。(丸投げ)

<class>

ソースコード中に痕跡がありますが、実装されておらず、パースもされません。

<sprite>

anim パラメータが指定可能です。アニメーションします。
<sprite=1 anim=0,16,12> と指定すると、スプライト 0 番から 16 番までを 12 fps で再生します。
ランタイムでのみアニメーションするので注意してください。

<margin>

left および right パラメータが指定可能です。
それぞれ <margin-left> ・ <margin-right> と同じ働きになります。

<action>

デバッグログが表示されます。それだけです。
おそらく未実装です。表示された瞬間に何らかのコールバックを呼べるようにする予定だったのでしょうか?

<scale>

<scale=2>hoge</scale> とすると、 hoge が横に 2 倍の長さで描画されます。
なんで…?

</table> ・ <tr> ・ <th>

パースはされますが実装されていません。
</table> は閉じタグだけ認識されます。
なお、 <td> はソースコード上に痕跡がありますが実装されていません。

おわりに

<sprite anim=...> を紹介したかっただけです。
なお、ソースコードを読んだ雰囲気からすると、 TODO や懸念点が書いてあったりするため、これらが正式にサポートされるかは微妙なところです。
自己責任でお使いください。