シェーダ言語ライクなベクトル演算の C++ 実装
ここでは、シェーダ言語ライクなベクトル演算を C++ 上で記述可能にする方法についてまとめます。注)2003/07/11 時点の記事(C++03 ベース)をもとに加筆修正し 2020 年 8 月時点の状況(C++17 ベース)を反映させた内容となっています。
その後 glm というプロジェクトが完全な glsl 互換ベクトル演算を実現してしまったため、 ここに書かれている内容は、実装の仕組みを考えるという趣味的探求以上の価値はありません。
シェーダ言語で使われるベクトル演算
シェーダ言語では、座標値や色など多次元の値を沢山利用します。 そのためシェーダ言語は、 これらを扱うためベクトル型およびベクトル型をサポートした数学関数を提供しています。コード例:色の加算
vec3 colorR = {1,0,0}; // 赤色
vec3 colorG = {0,1,0}; // 緑色
vec3 colorB = {0,0,1}; // 青色
vec3 colorW = colorR + colorG + colorB; // 白色
コード例:座標変換
vec4 localPos = {0,0,0,1}; // ローカル座標(3次元同次)
mat4 localToWorld = {{1,0,0,0}, // ローカル → ワールド変換(1,1,1 の平行移動)
{0,1,0,0},
{0,0,1,0},
{1,1,1,1}};
vec4 worldPos = localToWorld * localPos; // ワールド座標(3次元同次)
swizzle とは
シェーダ言語のベクトル型は、操作対象のメンバを swizzle と呼ばれる仕組みで指定できます。 実際のコード例を示します。
vec4 a = vec4(1,2,3,4);
vec3 b = a.xyz; // b = vec3(1,2,3)
この例では、a は 4 次元ベクトルですが、x y z の 3 次元成分のみを b にコピーしています。
この xyz が、swizzle と呼ばれるものです。
swizzle は、x y z w をどのような順序で並べても構いません。例えば以下のような記述も可能です。
vec4 a = vec4(1,2,3,4);
vec4 b = a.xyzw; // b = vec4(1,2,3,4)
vec4 c = a.wzyx; // c = vec4(4,3,2,1)
vec4 d;
d.wzyx = a; // d = vec4(4,3,2,1)
swizzle は、x y z w 以外にも、r g b a の組み合わせで指定することが可能です。
色情報を扱うベクトルの場合は、r g b a を使った方がコードが読みやすくなります。
vec4 a = vec4(1,2,3,4);
vec4 b = a.rgba; // b = vec4(1,2,3,4)
vec4 c = a.abgr; // c = vec4(4,3,2,1)
vec4 d;
d.abgr = a; // d = vec4(4,3,2,1)
swizzle はとても便利な機能です。
一度この便利さに慣れてしまうと、
シェーダ言語以外の環境でのグラフィクスプログラミングが億劫になるほどの中毒性があります。
C++ 化が難しい点
一般にベクトル演算の実装は C++ にとって難しいものではありません。 しかし、シェーダ言語ライクなベクトル演算の C++ 実装となると話は別で、 トリッキーな実装を沢山書かなくてはいけなくなります。 ここでは、具体的にどのような問題があるかをまとめます。- swizzle 対応
-
swizzle パターンから、操作対象のメンバをどう決定するのかという問題があります。
C# などの言語では、setter getter という仕組みが利用できるので、
swizzle を実現することは比較的容易です。
しかし C++ の場合、現状ではそれに相当する機能がありません。
近い動作をするものが、代入演算子です。
これと暗黙のキャストを利用して、似たような挙動を実現していく必要があります。
- コンパイル速度の高速化
-
ベクトル演算は広範囲のソースコードで利用されるものなので、
コンパイル速度はとても重要です。
難解な実装は、コンパイル速度を低下させます。
極力シンプルで、コンパイラに負荷を与えない実装に保つ必要があります。
- SIMD 化との両立
-
高速化のため、SIMD 化は必須です。
ただし、SIMD 専用型で値を保持するのは、
4 次元ベクトルの場合のみです。
つまりベクトル次元数によって、保持するメンバの型や構成が変えられるような実装にする必要があります。
- あいまいなシンボル問題
-
シェーダ言語ライクなベクトル演算では、同一名の関数のオーバーロードが多数存在します。
例えば pow 関数を例に挙げると、引数型はスカラ、ベクトル、スカラ・ベクトル混在など、
様々なバリエーションが存在します。
これらを混乱なく、あいまいなシンボルエラーが生じないように定義仕切るには、慎重なコーディングが必要になります。
さらに実用面を考えると、 ベクトル演算の C++ 実装が含まれる namespace を using namespace して利用することを想定しなければなりません。 このことは、ベクトル演算側にふくまれる sin cos tan abs などのシンボルが、 C/C++ の math.h 等で定義されるグローバルな同名シンボルと衝突することを意味ます。
- コンパイラフレンドリーであること
-
諸々の問題を解決していくと、実装が複雑化していきます。
特定コンパイラのみで発生するエラーに悩まされることになりがちです。
また、コンパイラバージョンアップに伴い、
それまで正常にコンパイルできていたコードが突然コンパイルエラーになってしまうこともあります。
将来に渡って問題を踏まないように、 極力ナイーブで単純明快なコードで記述していく必要があります。
実装
それでは、このようなシェーダ言語風なベクトル演算クラスを、C++ 上で実装してみます。 ここでは、要点をかいつまんで解説します。- swizzle のパターン展開には union を利用
-
ナイーブな最初の実装は以下のようなものです。
この時点では単に swizzle 毎に union メンバを定義しただけで、
まだ何の機能も実装されていません。
続いて、 ベクトルの次元数と swizzle 構造の情報を、 型情報として保持するクラスを用意します。 次元数は、メモリ上のフットプリントを示す次元数と、 演算上の次元数の 2 種類が必要になります。union Vec4 { private: float elements[4]; public: Vec4 xxxx; Vec4 xxxy; Vec4 xxxz; Vec4 xxxw; Vec4 xxyx; : : };
そしてこれを、型情報としてベクトル型に与えます。template<int opeDim_, int memDim_, int i0_, int i1_, int i2_, int i3_> struct Traits { enum { opeDim = opeDim_, // 演算上の次元数 memDim = memDim_, // メモリ上の次元数 i0 = i0_, // swizzle の第1要素のインデクス i1 = i1_, // swizzle の第2要素のインデクス i2 = i2_, // swizzle の第3要素のインデクス i3 = i3_, // swizzle の第4要素のインデクス }; };
template<typename Traits_t> union Vec { private: float elements[Traits_t::memDim]; public: Vec<Traits<4, Traits_t::memDim, 0,0,0,0>> xxxx; Vec<Traits<4, Traits_t::memDim, 0,0,0,1>> xxxy; Vec<Traits<4, Traits_t::memDim, 0,0,0,2>> xxxz; Vec<Traits<4, Traits_t::memDim, 0,0,0,3>> xxxw; Vec<Traits<4, Traits_t::memDim, 0,0,1,0>> xxyx; : : }
- 自分自身を自分自身で定義するクラスになってしまう無限再帰問題を回避
-
ここまでの実装は、Vec 型が Vec 型のメンバを持ってしまっています。
このままでは未完成の型を参照することになるため、コンパイルエラーになります。
そこで、自分自身を参照できるように、
テンプレートを使ったハックを入れます。
Vec 型に新たに recursiveCount というテンプレート引数を追加します。
そして swizzle 指定 union メンバには、recursiveCount をインクリメントしたVec 型を適用します。
recursiveCount は 0 から開始します。
具体的には以下のようになります。
これにより、Vec 型と union メンバで使われる Vec 型は、recursiveCount が異なる別の型と認識され、 コンパイルエラーは回避されます。template<typename Traits_t, int recursiveCount> union Vec { private: float elements[Traits_t::memDim]; public: Vec<Traits<4, Traits_t::memDim, 0,0,0,0>, recursiveCount+1> xxxx; Vec<Traits<4, Traits_t::memDim, 0,0,0,1>, recursiveCount+1> xxxy; Vec<Traits<4, Traits_t::memDim, 0,0,0,2>, recursiveCount+1> xxxz; Vec<Traits<4, Traits_t::memDim, 0,0,0,3>, recursiveCount+1> xxxw; Vec<Traits<4, Traits_t::memDim, 0,0,1,0>, recursiveCount+1> xxyx; : : };
しかしまだ不完全です。このままでは無限に再帰を起こしてしまうので、 やはりコンパイルエラーになります。 これを回避するため、template 特殊化を利用した再帰ストッパーを定義します。
これにより、recursiveCount が 1 に達すると再帰的に展開しなくなります。template<typename Traits_t> union Vec<Traits_t, 1> { private: float elements[Traits_t::memDim]; };
swizzle パターン展開時の負荷は、 swizzle 定義数の「recursiveCount 最大値」乗に比例して生じます。 recursiveCount が 2 以上になると、 コンパイル速度が劇的に低下し実用になりません。 recursiveCount 1 で再帰ストップさせることは必須事項となります。
- swizzle キャスト演算子の定義
-
swizzle 変換は暗黙のキャスト演算子として定義します。
swizzle 情報は、型情報として取り出します。
自分自身と引数の型を区別するため、
型情報は 2 種類必要になります
(名前に 0 が付くものが自分自身の型、1 が付くものが引数の型を示す)。
template<typename Traits0_t, int recursiveCount0> union Vec { : : public: template<typename Traits1_t, int recursiveCount1> inline Vec<Traits0_t, recursiveCount0>( const Vec<Traits1_t, recursiveCount1> ¶m ){ if constexpr (Traits0_t::opeDim >= 1) {elements[Traits0_t::i0] = param.elements[Traits1_t::i0];} if constexpr (Traits0_t::opeDim >= 2) {elements[Traits0_t::i1] = param.elements[Traits1_t::i1];} if constexpr (Traits0_t::opeDim >= 3) {elements[Traits0_t::i2] = param.elements[Traits1_t::i2];} if constexpr (Traits0_t::opeDim >= 4) {elements[Traits0_t::i3] = param.elements[Traits1_t::i3];} } : : };
- math.h 由来関数との衝突回避
-
シェーダ言語で用いられる sin cos tan 等々の数学関数は、
math.h でグローバルな namespace 上で定義されている同名のレガシー関数と衝突します。
この問題を自力で回避するのはとても面倒で、
コンパイル環境ごとに異なる対処が必要になるため、
環境切り分けの #ifdef が大量に発生することになるだけでなく、
未知・将来のコンパイル環境に対応できる保証もないという問題があります。
この問題のシンプルな解決方法は、namespace std 以下に含まれる数学関数を、 そのまま自分の namespace に持ってくるというものです。 std 以下に含まれる実装は、そのコンパイル環境で発生しうるシンボル衝突を、 各コンパイル環境毎にうまく回避してくれているので、それをそのまま流用すれば安全確実であり、 将来に渡って安心であるという考え方です。
以下は具体的なコード例です。 std にシェーダ言語互換動作の関数が含まれているならそれを using で取り出し、 互換でないもののみ独自に実装を定義しています(この例では引数を 2 つとる atan 関数)。using ::std::sin; using ::std::cos; using ::std::tan; using ::std::asin; using ::std::acos; using ::std::atan; static inline float atan(float y, float x){ return std::atan2(y, x); } static inline double atan(double y, double x){ return std::atan2(y, x); }
- SIMD 化
-
ベクトルが 4 次元の時だけ、SIMD 命令専用の型をメンバとして持つような仕組みが必要です。
これを実現するには、template 特殊化を利用したテクニックが使えます。
具体的には以下のようなコードになります。
このコードでは、IntrinsicType_t は float x 4 の時だけ __m128 に、
それ以外の場合は配列変数型になります。
この GenSimdVecTraits を使い、ベクトルの union メンバを追加します。template<int dim> struct GenSimdVecTraits { typedef float IntrinsicType_t[dim]; enum { isM128 = 0, }; }; template<> struct GenSimdVecTraits<4> { typedef __m128 IntrinsicType_t; enum { isM128 = 1, }; };
GenSimdVecTraits が持つ enum メンバである isM128 は、 __m128 型のメンバが存在することをコンパイル時点で認識可能にするために使うフラグで、 if constexpr と併用してコードの分岐に利用できます。 if constexpr で括ったコードは、有効にならない限りは解釈されません。template<typename Traits_t, int recursiveCount> union Vec { private: float elements[Traits_t::memDim]; GenSimdVecTraits<Traits_t::memDim> simdVec; // ここに追加した public: Vec<Traits<4, Traits_t::memDim, 0,0,0,0>, recursiveCount+1> xxxx; Vec<Traits<4, Traits_t::memDim, 0,0,0,1>, recursiveCount+1> xxxy; Vec<Traits<4, Traits_t::memDim, 0,0,0,2>, recursiveCount+1> xxxz; Vec<Traits<4, Traits_t::memDim, 0,0,0,3>, recursiveCount+1> xxxw; Vec<Traits<4, Traits_t::memDim, 0,0,1,0>, recursiveCount+1> xxyx; : : };
以下は、このテクニックを利用したコード例で、 __m128 型のメンバに対して 4 次元ベクトルとしてアクセスする swizzle が指定されたときのみ、 SIMD 命令専用メンバを使った高速なコピーが行われます。 一見、__m128 型のメンバを持たない場合コンパイルエラーになりそうですが、 その場合は if constexpr による分岐によりコード自体がスキップされるので、 問題なくビルドが通ります。if constexpr (SimdVecTraits_t::isM128 && Traits0_t::opeDim == 4) { __m128 tmp = v.simdVec; this->simdVec = tmp; } else { 通常の実装(elements 配列を 1 つずつコピーする) }
実際の実装はもっと複雑
実際の実装は、さらに泥臭く複雑です。 詳細はここには書ききれません (というよりは未完成と言った方が正確)。 実装は github 上で確認できます。https://github.com/yosshin4004/glslmath
2003/07/11 初出
2021/02/11 最終更新
文責: よっしん
[戻る]