HN 日本語サマリー

← 一覧へ戻る
プログラミング

浮動小数点数の小数部分がシェーダーを修正するとき

When the fractional part of a float fixes your shader (crocidb.com)

86 pointsby vinhnx16 コメント

要約

この記事は、音楽ビデオの背景に使用するために実装されたボロノイ図シェーダーで発生した奇妙なカクつきのデバッグ体験について詳述しています。この問題は特定のWindows PCでのみ発生し、最終的にはノイズ関数内の`fract`呼び出しにおける浮動小数点数の精度問題に起因することが判明しました。著者はシェーダーの実装、LLMを用いたデバッグプロセス、そして原因究明の過程を解説しています。

全文翻訳

floatの小数部分がシェーダーを修正したとき

先日、音楽ビデオの背景として使用する小さなVoronoiダイアグラムシェーダーを実装したいと思いました。Voronoiノイズは、私がこれまで実装したことのないアルゴリズムの一種で、その幾何学的でしばしば有機的な見た目に常に魅了されていました。あまり明確な方向性を持たずに実装と実験を開始しました。かなりクールな見た目になり、その日は終わりにしようとしていましたが、翌朝、私のコンピューターの1台だけで、アニメーションに奇妙なカクつきがあることに気づきました。そこで、問題を特定するために掘り下げることにしました。これは、1週間にわたるシェーダーのデバッグと逆アセンブルの冒険の記録です。物語にはいくつかのどんでん返しがあり、うまくいけば多くの興味深い情報も含まれているはずです。

[動画: 最終的なシェーダーのデモンストレーション]

これが最終的なシェーダーです。Shadertoyでも確認できます。まだビデオの作業はしていませんが、音楽はすべてのデジタルプラットフォームでチェックできます:stuffy knows。

このシェーダーはそれほど複雑ではありません。実際、いくつかの概念を組み合わせたものです。

Voronoiダイアグラム

Worley NoiseまたはVoronoi Noiseと呼ばれるもののバリエーションで、セルを互いに区別します。この場合、空間を等しいタイルに分割し、これらのタイル内にランダムな点(Voronoiセンター)を取得し、最後に各ピクセルから最も近い9つのVoronoiセンターまでの距離を計算します。最も近いものが、その点が属するVoronoiセルを定義します。これにより、モノクロでこれが生成されます。

[画像: 白黒のVoronoiダイアグラム]

voronoi diagrams in black and white

Voronoiノイズの非常に優れた入門は、The Book Of Shadersにあります。私がシェーダーを学び始めた頃に見た最初のリソースだったと思います。そこには他にも非常にクールなリソースがたくさんあります。

UVラッピング

これは空間の歪みです。空間を均等に分割する前に、ノイズを使用して空間を歪ませます。これをVoronoiダイアグラムに適用すると、これが得られます。

[画像: 空間の歪み]

distorting the space

パレットルックアップ

Voronoiセルは、基本的にセンターからの逆距離で色付けしました。そして、ついに、パレットから最も適切な色(私が望む順序であると仮定して)を取得し、パレットに7色しかないため、少しシェーディングを適用します。

const vec3 palette[7] = vec3[7](
	vec3(0.008, 0.451, 0.325), // #027353
	vec3(0.090, 0.275, 0.090), // #174617
	vec3(0.000, 0.455, 0.545), // #00748B
	vec3(0.949, 0.361, 0.745), // #F25CBE
	vec3(0.659, 0.580, 0.949), // #A894F2
	vec3(1.000, 0.725, 0.820), // #FFB9D1
	vec3(0.788, 0.949, 0.675)  // #C9F2AC
);

/// ...

col = palette[i] + vec3(val - .5) * .8;

これにより、これが生成されます。

[画像: シェーダーの最終的な見た目]

the final look for the shader

パレットを移動させることで、私が非常に楽しんでいるこのポップで動く効果も作っています。最終的なシェーダーはこちらで入手できます。

問題

翌朝、最初にシェーダーを構築したコンピューターとは別のコンピューターで作業していました。そのため、微調整を続けるために開いたときに、1つの問題に気づきました。

[動画: 問題のあるシェーダーのデモンストレーション]

以前は見えなかった奇妙なカクつきがありました。最初はFirefox(Windows上)で試しましたが、次にChrome、そしてEdgeで開きました。すべてのブラウザで同じ問題が表示されました。そこで、問題がどこにあるのかを見つけるためにエフェクトを削除し始めました。UVワーピング、パレットサイクリングを削除し、セルを大きくすると、問題がより明確になりました。

[動画: 問題が顕著になったシェーダーのデモンストレーション]

比較のために、本来はどう見えるべきかを示します。

[動画: 正常なシェーダーのデモンストレーション]

問題は、Voronoiセンターを生成するコードの部分にあるようでした。

// すべての点への距離を取得
for (int i = -1; i <= 1; i++) {
  for (int j = -1; j <= 1; j++) {
    vec2 o = origin + (vec2(i, j) * tile_size);
    vec2 v = o * 398.0 + vec2(iTime * 1.3, iTime * 1.4);
    vec2 c = o + noise2(v) / TILES; 
    
    float d = distance(uv, c);
    if (d < dist) {
      dist = d;
      point = o;
    }
  }
}

より具体的には、vとcを定義する行です。ノイズ呼び出しを手続き的に構成するルールと、その呼び出し自体です。どういうわけか、そのノイズ呼び出し内の何かが、このコンピューターでは別の方法で動作し、私の他のコンピューターでは動作しませんでした。そこで、これらの異なるデバイスでテストしました。

  • 統合Intel GPUを搭載した2台の異なるLinuxラップトップ:正常
  • Google Pixel 9 Proフォン:正常
  • RTX 2070を搭載したLinux PC:正常
  • RTX 4070を搭載したWindows PC:破損

これらの5つの異なるデバイスのうち、カクつきが発生したのは最後の1つだけでした。ほとんどすべてで異なるブラウザも試しました。

グラフィックスプログラマーの友人に尋ねましたが、忙しすぎて手伝ってもらえませんでした。そこで、手元にあるツールでデバッグすることにしました。

ノイズ関数

シェーダープログラミングを全く知らない人のために、簡単な紹介をします。シェーダーはGPUで実行されるプログラムです。グラフィックスパイプラインのどの部分にいるかによって、いくつかの種類のシェーダーがあります。Shadertoyはフラグメントシェーダー(またはピクセルシェーダー)を使用します。これは、GPUがオブジェクトの頂点をラスタライズした直後のステージであり、このプログラムが最終的なピクセルカラーを生成する責任を負います。プログラムは基本的にフレームごとにピクセルごとに実行されます。状態を保存できないため、副作用はありません。関数型プログラミングを考えてください。シェーダー全体が純粋な関数のようなもので、いくつかの入力を受け取り、それに基づいて常に同じ出力を生成します。

それがShadertoyを面白くしている理由の一部です。私のシェーダーで常に変化する2つのパラメータは次のとおりです。1. 現在のピクセルの座標。2. 時間変数。前者は2Dベクトルとして渡され、コンポーネントは0.0fから1.0fの範囲です。後者はfloatで、フレームごとにしか変化しません。

ランダムな値を得るには、ハッシュ関数に依存し、その後、それを平滑化するために手続き的なノイズを作成する必要があります。このシェーダーで使用しているノイズ関数は、Shadertoyの作成者であり、私が知る最も影響力のあるグラフィックスプログラマーの一人であるInigo Quilezによる手続き型ノイズの記事から取ったものです。記事とはわずかに異なりますが、2019年以来、私が書いたほぼすべてのシェーダーで同じコードを使用しています。

以下は、このシェーダーで使用されている完全なノイズコードです。理解する必要はありませんが、基本的に異なる値のハッシュを取得し、それらを補間することで、実質的に出力を平滑化します。よりよく理解したい場合は、Inigo Quilezの記事を確認してください。

float hash1(float n) {
  return fract(n * 17.0 * fract(n * 0.3183099));
}

float noisev(in vec3 x) {
  vec3 p = floor(x);
  vec3 w = fract(x);

  vec3 u = w * w * w * (w * (w * 6.0 - 15.0) + 10.0);

  float n = p.x + 317.0 * p.y + 157.0 * p.z;

  float a = hash1(n + 0.0);
  float b = hash1(n + 1.0);
  float c = hash1(n + 317.0);
  float d = hash1(n + 318.0);
  float e = hash1(n + 157.0);
  float f = hash1(n + 158.0);
  float g = hash1(n + 474.0);
  float h = hash1(n + 475.0);

  float k0 = a;
  float k1 = b - a;
  float k2 = c - a;
  float k3 = e - a;
  float k4 = a - b - c + d;
  float k5 = a - c - e + g;
  float k6 = a - b - e + f;
  float k7 = -a + b + c - d + e - f - g + h;

  return (k0 + k1 * u.x + k2 * u.y + k3 * u.z + k4 * u.x * u.y +
          k5 * u.y * u.z + k6 * u.z * u.x + k7 * u.x * u.y * u.z);
}

問題を見つけるための最初の試み

友人がデバッグの方向性を示してくれなかったので、LLMを起動して問題と完全なコードを説明しました。最初はハッシュ関数に連続性の問題がないか分析していましたが、このコードは何千ものShadertoyシェーダーで使用されているため、良い考えではないと思いました。そこで、それをそらしました。次に、ノイズ関数の冒頭にあるfractの呼び出しに問題がある可能性があると判断しました。どうやら、Nvidia 40xxドライバーのfractに関する問題について、オンラインで苦情を言っている人がいるようです。

fractはfloatの小数部分を取得します。

LLM(Kimi K3)の思考プロセスを読み進めると、カクつきがコード実行後しばらくしてから始まることを想定していることに気づきました。これは確かにfloatの精度を低下させ、視覚的なカクつきを引き起こす可能性があります。これはよくあることですが、この場合、最初の瞬間からカクつきが発生していました。しかし、最初に提案された解決策を試すことにしました。fract(x)を数学的に等価なx - floor(x)に置き換えることです。これはすでにpとして計算されています(正確なfp32減算)。

そして、それは機能しました。問題が解決しました。Voronoi頂点がスナップするのを見なくなりました。説明を求めると、次のような回答がありました。

「根本原因: Nvidiaドライバー595.79(RTX 4070)は、このシェーダーのコンテキストで、大きな絶対値のオペランド(約100〜1000)に対してGLSLのfract()の使用を誤ってコンパイルします。返される小数部分は時間的に不連続になります(滑らかに移動してからジャンプします)。証明:fract(x)を数学的に同一のx - floor(x)(すでにpとして計算されている—正確なfp32減算)に置き換えると、影響を受けるマシンでのスナップが解消され、正常なGPUではビット単位で同一の出力が得られます。コンパイラが異なる命令を発行するだけで同じ方程式を異なる方法で計算するのは、定義上、誤コンパイルです。シェーダーへの影響: fract(v)は値ノイズの補間重みです。それが間違っていると、ノイズ出力は滑るのではなくテレポートし、Voronoiセンター(c = o + noise2(v)/TILES)もそれに従ってテレポートします。」

この時点で、私は人生を続けることができました。シェーダーは動作し、微調整やコードの改善に戻ってから公開することができました。しかし、私は好奇心が強く、あまり確信が持てなかったので、さらに掘り下げることにしました。

問題の再現

自然な次のステップは、問題を再現できる最小限のコードを見つけることです。LLMに尋ねましたが、すでに修正につながった仮説を持っていたため、失敗しました。モデルを何度か変更し、Opusのようなプロプライエタリなモデルにも変更しましたが、どれも問題を再現する単一のプログラムを作成できませんでした。

生成されたテストシェーダーはすべて、fractが特定の入力値範囲に対してガベージ値を生成するという仮説に基づいており、期待値x - floor(x)と問題のある値fract(x)の差を表示するバリエーションでした。これらのテストで興味深い結果は、すべてのデバイスで違いがないか、またはエラーが問題のあるデバイスだけでなく、発生していることでした。

これにより、GPUドライバーの問題であるという仮説全体が無効になりました。LLMは偶然にも解決策を見つけただけでした!

LLMは忘れて、手作業で行おう

要素を移動させ、ノイズ関数を呼び出すループを単純化する方法を考えましたが、同様のパラメータを維持しました。また、ノイズに異なる値を渡してテストしたところ、最初のどんでん返しを発見しました:ノイズに渡されるパラメータに少なくとも1つの小数float乗算が含まれている限り、シェーダーは正常に機能しました。例えば、元のコードのこの行を見てください。

vec2 v = o * 398.0 + vec2(iTime * 1.3, iTime * 1.4);
vec2 c = o + noise2(v) / TILES;

スカラー乗数を398.0から398.1に変更するだけで十分でした。

vec2 v = o * 398.1 + vec2(iTime * 1.3, iTime * 1.4);

カクつきが消えました。fractがノイズコードに残っていてもです。これが最も重要な証拠でしたが、最も奇妙でもありました。1.0で乗算したり、乗算を完全に削除してもカクつきはありましたが、1.001のように戻すと修正されました。

逆アセンブルの時間です。1つのリテラルfloat値を変えるだけで最終的なマシンコードに何が変化するのかを知りたいです。

シェーダーの逆アセンブル

シェーダーのデバッグ経験はあまりなく、GPUアーキテクチャに関する知識はほとんどありません。私のグラフィックスに関する知識は、パイプライン(以前3Dレンダラーとゲームエンジンを作成しようとした経験から:annileen)に焦点を当てていました。しかし、このような問題を調査することは楽しいですし、GPUアセンブリの知識が少なくても、それを見るだけで多くのことを理解できると知っています。

annileenを開発していた頃、フレーム全体のグラフィックスパイプラインを掘り下げることができるオープンソースのグラフィックスデバッガーソフトウェアであるRenderDocをいくつか使用する必要がありました。これには、各シェーダーのコンパイル済みバージョンと、それに入出力されたすべてのデータが含まれます。しかし、WebGLコンテキスト1つだけのためにブラウザ全体をデバッグするのはやりすぎだと考え、LLMに再度依頼して、同じモデルのGLSLを実行し、Shadertoyと同じユニフォームを渡すOpenGLのShadertoyラッパーを生成させました。ネイティブプログラムがshader.glslをロードし、Shadertoyとまったく同じように表示するものです。

数トークンを消費してプログラムを実行しましたが…カクつきはありませんでした。正しいコードを使用していることを確認しましたが、エラーを再現できませんでした。OpenGLとWebGL(OpenGL ES)に関連する何かだと仮定し、そのテストを破棄しました。ブラウザフレームをキャプチャする必要がありました。

RenderDocでブラウザフレームをキャプチャする

私は、それぞれ特定のタブ設定を持つ複数のブラウザをインストールしているという悪い癖があります。そこで、クリーンなバージョンのChromiumをダウンロードしました。RenderDocでフルキャプチャを行うためのChromiumセッションの起動方法として、次のコマンドラインパラメータを使用することがどこかでわかりました。

--disable-gpu-sandbox --disable-gpu-watchdog --no-sandbox --ignore-gpu-blocklist --enable-webgl --use-angle=d3d11 --disable-direct-composition

そして、複数のプロセスを作成するため、子プロセスからのキャプチャも有効にしました。それを起動し、シェーダービューポートのみを持つ別のラッパーを開いた後、F12でフレームをキャプチャし、それらのキャプチャを開くことができました。これらは子プロセスに隠されています。

[画像: モダンブラウザは複数の子プロセスを起動します]

modern browsers spawn several child process

キャプチャは常に2番目の子プロセス内で行われていることが判明しました。

[画像: 異なる値で2回行ったキャプチャ]

two captures I did with different values

元のシェーダー(398.0の値を使用)と398.1の値を使用した場合の2つのキャプチャを作成しました。

RenderDocのEvent Browserで正しい描画呼び出しを見つけるだけで、シェーダーの逆コンパイルを見つけることができました。

[画像: シェーダーが描画される非常に特定の描画呼び出し]

the very specific draw call when my shader is drawn

次に、パイプラインステータブに移動し、ピクセルシェーダーを選択します。

[画像: ピクセルシェーダー 31872]

Pixel Shader 31872

次に、シェーダープログラムの前の表示ボタンをクリックします。

[画像: ついにシェーダーの逆アセンブル]

finally the shader disassembly

この時点で、私は非常に重要なことを見落としていました。シェーダーがps_5_0形式であるという事実です。これはDirectX 11の形式であり、OpenGLとは全く関係ありません。いずれまたこれに戻るでしょう。

単一のリテラル値を変えるだけで出力コードに大きな違いが生じたという事実は、私には最適化の問題のように思えました。そこで、アセンブリがDirectX形式であることを確認しました。ブラウザのレンダリングAPIを確認したところ、すべてDirectX 11で実行されていることが確認できました。Chromeではchrome://gpu、Firefoxではabout:supportです。WebGLはOpenGL ESを実行するはずだと考えました。それからANGLEについて知りました。

ANGLE

ANGLEはGoogleがChromeのために作成したプロジェクトで、WebGLを異なるグラフィックスAPIの下で実行できるように、各APIのシェーダー言語にGLSLシェーダーをトランスパイルします。現在、WindowsプラットフォームではChromeだけでなくFirefoxでも使用されています。そして、私がこれまで考えていなかった唯一のデバイス変数はOSでした。私のテストデバイスすべてで、Windowsを実行していたのはそれだけでした。

ブラウザはDirectX 11でレンダリングしていたため、ANGLEはGLSLシェーダーをHLSLにトランスパイルし、DirectXがシェーダーをコンパイルしていました。RenderDocで見つかった逆アセンブリは、そのバイトコード、DXBC(DirectX Byte Code)からのものです。その時、RenderDocで取得できるアセンブリコードは、私が到達できる最も遠い地点であることを思い出しました。これは、VulkanとDirectXのGPUで実行されている実際の最終コードの逆アセンブリを比較できないことを意味します。

そこで、次のアイデアは、ANGLEによってトランスパイルされた中間HLSLコードを、DXBCになる前にインターセプトする方法を見つけることでした。その方法は、Chromeに次のコマンドライン引数を渡すことです:--enable-angle-features=dumpTranslatedShaders。これにより、環境変数ANGLE_SHADER_DUMP_PATHで指定されたパスにコードがダンプされます。

驚くべきことに(あるいはそうでもないかもしれませんが)、最終的なHLSLはGLSLとほぼ同一でした。派手な最適化やその他のものは何もありませんでした。実際、2つの言語は非常に似ています。以前BGFXで作業していたとき、DirectX 11用にGLSLをHLSLに変換するパイプラインも持っていたことを思い出しました。そのプロセスは非常に簡単でした。

さらに別の仮説が無効になりました。次の仮説:問題はHLSLシェーダーコンパイラにあります。

HLSLコンパイラのテスト

実際の問題に近づくために、適切なDirectX 11 Shadertoyラッパーが必要でした。そこで、LLMに非常に迅速に生成させました。次に、元のGLSLを手動でHLSLにトランスパイルし、Windows DirectX 11レンダラーでネイティブにバグを再現することができました。

初期の仮説が最適化エラーであったため、DirectXがシェーダーをどのようにコンパイルするかを調べ始めました。DirectX 11はHLSLをDXBCにコンパイルするためにFXCを使用します。コンパイル時に、最適化レベルを選択するフラグがあります。デフォルトでは、ANGLEはO3を使用すると想定しており、それに従いました。最適化を完全にスキップすると、動作するシェーダーが得られました。これは最適化の問題です。

HLSL Decompiler(RenderDocの拡張機能)を使用して、DXBCを動作するHLSLに逆コンパイルし、最適化が何をしているのかを簡単に確認できるようにしました。

コードのこの部分を考慮してください。

float2 v = o * 398.0 + float2(iTime * 1.1, iTime * 1.1);
float2 c = o + noise2(v) / TILES;

そしてnoise2を考慮してください。

// 基本的に`noisev`の2回の呼び出しをラップします
float2 noise2(float2 v) {
  return float2(noisev(float3(v, 0.0)), noisev(float3(v, 18.0)));
}

// noisevは次のように始まります:
float noisev(in float3 x) {
  float3 p = floor(x);
  float3 w = frac(x);
  // (...)
}

最適化なし(D3DCOMPILE_SKIP_OPTIMIZATION)で逆コンパイルすると、次のようになります。

r5.zw = float2(398,398) * r5.xy;
r6.x = 1.10000002 * iTime;
r6.y = 1.10000002 * iTime;
r6.xy = r6.xy + r5.zw;
r6.xy = r6.xy;
r6.z = 0;
r6.xyz = r6.xyz;
r7.xyz = floor(r6.xyz);
r8.xyz = frac(r6.xyz);

398の値が見え、vec2を初期化しています。それをr5.xy(元のシェーダーのoかもしれない)で乗算した後、iTimeの変更が追加されます。その直後に、生成された値を使用したfloorとfracの呼び出しが見えます。ノイズ関数の冒頭と同じです。

最適化をオンに戻すと(O3)、見えるのは次のとおりです。

r3.xz = r5.yz * float2(398,398) + r1.xx;
r3.xz = floor(r3.xz);
r3.x = r3.z * 317 + r3.x;
r3.zw = float2(17,0.318309903) * r3.xx;
r3.w = frac(r3.w);
r3.z = r3.z * r3.w;
r3.z = frac(r3.z);

類似のものが見えます。398をr3.xzにvec2を乗算して代入していますが、次の行で、r3.xzをそれ自身のfloorに再代入しています!これにより、その値が切り捨てられ、小数部分が完全に失われます。したがって、スナップ移動が生成されるのはここです:値を切り捨てることによって。

例として、座標を398.0ではなく398.1で乗算した場合の正確な部分を示します。O3でコンパイルされています。

r4.yz = r0.zw * float2(0.100000001,0.166666672) + r2.yz;
r5.xy = r4.yz * float2(398.100006,398.100006) + r1.xx;
r5.zw = floor(r5.xy);
r5.xy = frac(r5.xy);

全体が完全に異なります。値が小数であるものに切り替わっただけですが、fract呼び出しはまだそこにあります。FXCコンパイラは、渡された値が小数floatであるという明確な兆候がない場合、fract呼び出しを最適化で削除しているようです。r5.zw = float2(398,398) * r5.xy;という行は非小数floatを渡しますが、r5.xy(または元のシェーダーのo)にも小数部分が含まれていないと仮定しているようです。

プロプライエタリなシェーダーコンパイラであるため、FXCのソースコードを検査することさえできません。幸いなことに、DirectX 12の新しいシェーダーコンパイラはオープンソースです。

次は何?

この時点で、私は結果に満足しています。シェーダーコーディングの夜が、1週間にわたるシェーダーのデバッグと学習に変わりました。しかし、このケースではまだ多くのことをすべきだとわかっています。最小限の再現可能なシェーダーをまだ入手したいです。グラフィックスプログラミングの経験があり、さらに調査を続けたい場合は、ぜひそうしてください。見落とした情報があれば教えてください。

シェーダーの微調整はまだ進んでいません。見た目はかなり良く、間違いなく音楽ビデオに取り組むつもりです。もしここまで読んで、まだ私の音楽プロジェクトを聴いていないなら、こちらです:stuffy knows。