ハクソク

世界を動かす技術を、日本語で。

三角法を避ける (2013)

概要

  • 3Dグラフィックス内部で三角関数を多用することへの懸念
  • ベクトル演算(内積・外積)によるシンプルで堅牢な実装の提案
  • 典型的な回転行列生成の非効率な方法とその問題点の解説
  • 三角関数を排除した効率的な回転行列計算方法の提示
  • ベクトル演算を中心とした直感的で高性能なコードの推奨

3Dグラフィックスにおける三角関数の過剰利用について

  • プロジェクションやリフレクション、ベクトル演算の理解が深まるごとに、3Dアルゴリズム内部で三角関数を見かけるたびに違和感を覚えるようになった経験
  • sin, cos, tan, asin, acos, atan などの三角関数が3Dエンジン内部で現れる場合、非効率でエラーを招きやすい設計である可能性
  • 三角関数はデータ入力やアルゴリズムの外部インターフェースでは便利だが、内部処理ではベクトル演算で置き換えるべきである主張
  • 内積(dot product)と外積(cross product)は、cosineとsineの情報を完全に内包しているため、ベクトルとジオメトリの操作で十分
  • 角度や三角関数を途中で持ち出す設計は、複雑化・冗長化・エラーの温床となる懸念

非効率な回転行列生成の例

  • 多くの3Dエンジンやライブラリで見られる、軸と角度から回転行列を生成する関数 rotationAxisAngle()
    • 例: mat3x3 rotationAxisAngle(const vec3 &v, float a)
  • オブジェクトのz軸を任意の方向ベクトルdに合わせる際、zとdの内積からacos()で角度を求め、cross()で回転軸を算出し、normalize()して回転行列を生成する典型的な流れ
  • この手法では、dot→acos→cos/sin→回転行列 という非効率な計算チェーンが発生
    • acos()の直後にcos()を使うと「cos(acos(x)) = x」となり、無駄な計算
    • clampやnormalizeも必要になり、パフォーマンスや精度の悪化を招く

ベクトル演算による効率的な回転行列の導出

  • sin(θ)は、zとdが単位ベクトルならcross(z, d)の長さと一致
    • これにより、三角関数を使わずにcos(θ)とsin(θ)を求められる
  • 三角関数やnormalize、clamp、sqrtを排除した回転行列生成コードの例
    • まずは sin, cos をベクトル演算で算出
    • さらに式変形により sqrtも排除し、k = 1/(1+c) の形に簡略化
  • 最終的な回転行列生成関数(k = 1/(1+c))
    • ベクトル演算のみで安定・高速な実装

結論と推奨

  • 三角関数やその逆関数、clamp、normalize、sqrtを排除した実装は、安定性・パフォーマンス両面で優れる
  • ベクトル同士の問題はベクトル演算のみで解決するのが本質的
  • 多くの三角関数の公式は、実はベクトルの特定の配置に関する記述であり、ベクトル演算の直感を身につければ暗記も不要
  • 外部APIや既存ライブラリの設計に引きずられず、ベクトル演算中心の設計を推奨
  • パフォーマンス・安定性・可読性の観点からも、3Dグラフィックス内部では三角関数の利用を極力減らすべき

まとめ

  • 3Dレンダリングエンジン内部での三角関数多用は、冗長・不安定・非効率であることが多い
  • ベクトル演算(内積・外積)中心の設計により、シンプルかつ堅牢なコード実現
  • 既存APIの設計に惑わされず、本質的なベクトル処理を心がけるべき
  • 三角関数を使わずに回転行列や方向ベクトルの処理を行うノウハウの重要性

Hackerたちの意見

これは、私にとってちょっとしたイライラと啓発の瞬間が混ざったようなものだった。投稿の主張には心から同意するよ。最近のasin()の投稿でもこのことについてコメントしたけど、一般的に興味がないかもと思って削除しちゃった。平面での角度や回転に興味があるなら、角度をスカラー(度数やラジアン)で表すのではなく、タプル(cos θ, sin θ)や複素数で表すのがよくある。そうすると、高価な三角関数を呼び出さずに済むことが多いんだ。平方根や一般的な多項式の根を見つけるための呼び出しが必要になることもあるけど。Pythonでは、角度を単位複素数として表現できて、ランタイムが計算をしてくれるよ。例えば、原点で形成される角度の角平分線が必要な場合(頂点をそこに移動させて、後でその移動を元に戻せばいい)、その平分線は角度の両腕の幾何平均、つまりsqrt(z1 * z2)になる。ステレオグラフィック変換とその逆を使えば、色々なことができる。これは代数的数の分野に直接関係してる。複素数を使うことで、平行移動、スケールされた回転、反射ができる。ユークリッド幾何学には十分だね。
ノーマン・ワイルドバーガーは、合理的三角法でこれを極端に進めているよ。角度を完全に排除して、比率にこだわってる。平方根を避けるために「クワランス」(平方距離;つまり、平方根を取らずにピタゴラス/ユークリッド距離を使う)に頼っている。ワイルドバーガーのYouTubeチャンネルもおすすめだよ。彼はかなり反対意見を持っているから、彼の非公式な発言は少し疑ってかかった方がいいかも(例えば、実数なんて存在しないって言ってるけど、その根底にある議論は合理的だけど、大きな主張はそのニュアンスを失ってしまう)。でも、彼は多くのテーマに対して面白い視点からアプローチしていて、射影幾何学や線形代数などの間の素晴らしいつながりを提示している。
彼は反対意見を持つと見なされるかもしれないけど、彼の数学はしっかりしてるよ。
こういうことが、私がエイリアンが私たちが不変の基本と考えているものをどう使うかを想像するのに本当に興味を持つ理由なんだ。
彼は素晴らしい人だね。たとえ私が彼を完全な変わり者だと思うとしても、彼の存在がみんなの人生を明るくしてくれるような変わり者なんだ。
こちらも見てみてね。
うん、これはすごく興味深いね。自分のコードや記事の主なポイントを考えた結果、独自に角度が三角関数を引き起こすって結論に達したよ。人々が角度を中間値として使っているかもしれないのは同意するけど、個人的にはそれが最も現実的な抽象化になるケースもあると思う。例えば、ユーザーのマウスの動きやボタンの押下を回転の変化にどうやってマッピングするの?スカラー値なしで?三角関数なしで?ユーザーはカーソルやスティックを何ピクセル/ユニット動かす。ユーザーはキーを何ミリ秒押し続ける。これはスカラーだよ:整数か浮動小数点。三角関数を避ける人たちに聞きたいんだけど、どうやってベクトルやマトリックス、クォータニオンのシステムにスカラー値を導入するの?
回転を二回の反射として見る別の視点に集約されると思う。そうすれば、ハウスホルダー行列を使って三角法を避けることができる。この幾何学的な数学のトリックは、効率的な計算に役立つことがある。例えば、ベクトル量子化変分オートエンコーダ(VQ-VAE)を回転トリックを使って改善し、ハウスホルダー行列を使って一つのベクトルを別のベクトルにマッピングする最適な回転を効率的に計算できる。質問として、なぜ誰かが三角法を避けるのかというのもあるね。三角法は三角形の研究に関連していて、回転の概念と自然に結びついている。回転は指数関数と関連する非常に豊かな概念だよ(掛け算は繰り返しの足し算、指数関数は繰り返しの掛け算)。繰り返しの作業は発散しがちだけど、回転は自己安定化するから、宇宙の構成要素として良い候補になる。これらの操作は可換でないから、単純な操作の順序から驚くべき複雑さが生まれるけど、構造的に安定している。
ウィキペディアの三角関数のページを引用するのは、まるでLLMに期待されるコメント形式を教えて、洞察に満ちたコメントを書かせたみたいだね。
これは著者が言っているよりも主観的だと思う。私は第三のアプローチを取るよ:行列をクォータニオンに置き換えることができる。そうすれば、ほぼすべての操作をこの二つのタイプとその間のいくつかの操作を使って行える。操作の実装は、ドット積やクォータニオンの掛け算、三角関数などのミックスだ。これらの流れは、いくつかの操作を組み合わせて任意の複雑な変換を構築するような感じで、頭の中に留めやすいからうまくいくと思う。もしかしたら、単に慣れただけかもしれないけど、効果的なパターンを見つけることが重要だね。例えば、>「例えば、宇宙船をアニメーションパスに合わせるとき、宇宙船のz軸をパスの接線または方向ベクトルdに合わせるようにする。」というのがあるかも。こうなると、`let ship_z = ship.orientation.rotate_vec(Z_AXIS); let rotator = Quaternion::from_unit_vecs(ship_z.to_normalized(), path.to_normalized()); ship.orientation *= rotator;` これを個々の相互作用に分けて、記事の二つの例と比較すべきだね。まず、`from_unit_vecs`は外積に基づいていて、`rotate_vec`はクォータニオン-ベクトルの掛け算に基づいている。だから、そこには三角関数は使ってない。でも、`quaternion::from_axis_angle()`はsinとcosを使う。冗長な操作について警告している部分を見直す必要があるけど、ざっと見た感じでは、SLERPにacosを使っていて、二面角を計算しているけど、基本的な構成要素ではない。atanは使ってないから、まあ大丈夫かな?編集:洞察:私のコードでの三角関数の使用は、角度が概念の一部であるときだけのようだ。何かがベクトルとクォータニオンだけなら、そのままの状態を保つ。角度が導入されると、三角関数が出てくる。記事について言うと、宇宙船の整列の例では、角度を導入しないから、三角関数は使わない。でも、明示的な角度が必要なケースはたくさんあると思う(ユーザーとのインタラクションを考えてみて)。
> マトリックスをクォータニオンに変えることもできるよ。スピン群を使うのがいいね。どの次元でも使えるし。
大きな視点でアップデートすると、記事の中の rotationAxisAngle の例はいいと思うよ。問題はそれが存在して角度や三角関数を使っていることじゃない:その関数には正当な使い道があるからね!問題は宇宙船を整列させるための最適なツールじゃないってこと。だから、問題は関数や角度、三角関数じゃなくて、間違ったツールを使ってることなんだ。
三角法の使用はほとんどいつも臭いがするっていうのには同意するけど、ゲームの中では角度がもっと便利で直感的なケースがたくさんあるよ。自分のゲームで「angle」をgrepしてみたら、ビルボードパーティクルの向きを調整するために使っていることがわかった(特にパーティクルの場合、単一の角度はクォータニオンよりもずっと良い)。FPSカメラコントローラーにも使ってる。ピッチとヨーを保存して、マウスの動きでそれを変える方が、クォータニオンを保存するよりもずっと簡単だよ。クォータニオンを見ても、どんな回転を表しているのか分からないから、計算機を開かないといけない。あと、角度の「ごまかし」にも使うから、何かにインタラクトしたいとき、ざっくり見ているだけでいいように、許可される角度範囲を設定する必要がある。これを角度として設定するのが理にかなっているのは、角度に対する直感があるからだと思う。だから、計算に関しては角度はたいてい間違っているかもしれないけど、直感には素晴らしい(低次元で回転の量が線形だから)。それが回転のための人間のインターフェースとして優れている理由だね。そして、計算が角度から始まると、もちろんそれがコードの他の部分にも影響を与える。
まあ、誤解しないでほしいんだけど。三角関数は便利だし、データ入力や大きなアルゴリズムに必要なんだよね。
ピッチとヨーを保存するのは、任意のカメラのロールが必要になったり、オリエンテーションの間で補間が必要になると、ジンバルロックのせいでうまくいかなくなるんだ。小さなUIの部分や平面のオブジェクトには角度を使うのはいいけど、ビルボードパーティクルが複数の自由度を必要とする場合、結局クォータニオンが必要になることが多いよ。クォータニオンは不透明だけど、変換関数やデバッグビューがあれば、実際に何が起こっているかを読むのに役立つんだ。三角関数のショートカットは、シンプルな動きや制約のある動きには効果的だけど、スケールアップすると厄介なエッジケースが出てくることが多いね。
>「設計が悪いサードパーティのAPI」 これがなぜこういうAPIが設計されているのかという理由を見逃していると思う:便利で直感的だからだ。こういうパフォーマンスが重要になることは稀だし、こういうコードの小さな不正確さが問題になることもほとんどない。確かに、より良い合成関数を書くことができるけど、それは完全に新しい関数を書く必要があるってことでもある。シンプルで理解しやすく、再利用可能な表現に分解するのは良いことだ。この種の数学の複雑な部分はコードではなく、やろうとしていることを抽象化された概念のセットに分解することだから、メンテナンスの悪夢にならないようにすることだ。これがもっと現実的なライブラリで明らかに表れるのは、軸角回転が多くの便利な関数が付随した強い型である可能性が高いからで、あなたの生活を楽にしてくれる。数学には常に抽象化のペナルティがあるけど、通常は時間を節約できるから、その価値はある。99.9999%の時間、単に重要でないから。これに加えて、このコードは-ffast-mathで最適化されるから、ほとんどの時間には関係ない。みんな「この三角法は冗長だ、ああ!」って思う時期を通ると思うけど、一般的にはソフトウェアエンジニアリングが優先されるんだ。
いろんなゲームを作ってきた経験から言うと、たまに起こるランダムな物理エンジンの爆発を除けば、三角関数がバグの大きな要因になってる気がする。年々、言及された問題のせいで無意識に三角関数を避けるようになったと思うけど、特にカメラの回転みたいなことにはやっぱり角度に頼っちゃうんだよね。OPがこの crusade を自分のプロダクションコードでどこまで進めるのか気になるな。
> こういうAPIがこのように設計されている理由が欠けていると思う:便利で直感的だから。 その通りだね。私の見解では、著者が考えた方法は一般の人々、つまり私を含めて、直感的とは言えないと思う。
いい記事だね!グラフィックスプログラマーじゃないけど、数学的にはクロスプロダクトが `sin()` を使うよりも大幅な最適化になるのは納得できるよ。複雑さの観点から見ると、クロスプロダクトの計算は形式的な行列式を計算することに帰着するし、固定された数の算術演算になるから、O(1) の複雑さになるんだ。一方で、`sin()` の計算は O(M(n)log(n)) だよ(実際にはもっと速いアルゴリズムが可能なことも多いけど)。Brentの「Fast multiple-precision evaluation of elementary functions」(1976)を参照してね。
グラフィックスプログラマーにとって、acos(dot(x, y))はいつも気になるところだよね。大抵の場合、実際に欲しいのはcos(theta)だし、角度が必要だと思っても、実はそうじゃないことが多いんだよね。
彼はまだcross(z, d)とdot(z, d)を別々に計算してるね。それってコードの匂いがする。クォータニオンを使えばもっと簡単になるのに:zとdの間の商を計算して、平方根を取るだけ(つまり1を足して正規化する)。ベクトルを扱う場合、平方根は必要なんだ。ベクトルは一種の四角い空間に存在するからね。二つのスピノールの間の回転を見つけるのはさらに簡単で、スピノールをクォータニオンとして扱うだけで商を取るだけなんだ。残念ながら、ハミルトンの「クォータニオンはベクトルの商である」という見解は完全には捨てられていないんだよね。スピノールの商として考える方がずっと自然なんだけど。
> 彼はまだcross(z, d)とdot(z, d)を別々に計算してるね。それってコードの匂いがする。クォータニオン... 確かにそうだけど、Projective Geometric Algebraのスペルを間違えてると思うよ。
ドット積とクロス積は同じ操作だけど、座標に展開されているんだ。クォータニオン(または幾何学的代数)のバージョンはもっとコンパクトかもしれないけど、計算のセットが違うわけじゃない。対して、三角関数を取り除くことで、実際にいくつかの不要なステップを省いてるんだよね。