はじめに
自力でごりごり DirectX とか OpenGL とか WebGL とかシェーダとか触りはじめた人向けに。
テクスチャとフレームバッファの色を乗算済みアルファ(premultiplied alpha)で扱っていれば、
2D描画などでは、アルファブレンドと加算合成は描画分ける必要ないよ、というお話です。
低レベルグラフィックスの入門書とかだと、ブレンド方法の変更をレンダリングステートの設定(WebGL なら blendEquation
や blendFunc
) で行っていることが多いようです。
もちろん何も間違ってはいないのですが、レンダリングステートを変更するということは、ドローコールを分ける、ということでもあります。
内部的にはパイプラインの変更が挟まる可能性があり、実行効率の面から言うとあまり頻繁に変更したいものではありません。
ゲーム等でよく使う、アルファブレンドと加算合成に関しては、レンダリングステートの変更なしに使い分ける小技があります。
知っておくと描画負荷を結構さげられるかも?
実例
See the Pen Seamless Alpha / Additive Blend by nagtkk (@nagtkk) on CodePen.
WebGL で作ってみた簡単なサンプルです。
「加算合成度合い」をいじると、アルファブレンドと加算合成をシームレスに行ったり来たり出来ます。
また「一部のみ加算合成」にチェックを入れると、奇数番目のスプライトを強制的にアルファブレンドにします。
「加算合成度合い」を最大にして、チェックを入れれば、アルファブレンドと加算合成が共存できていることを確認できるかと思います。
これらの描画は所謂スプライトバッチで、1回のドローコールで描画しています。
アルファブレンドと加算合成で、レンダリングステートの変更を行っていません。
解説
乗算済みアルファ、というのは、R,G,B 各要素に A を事前に掛けたもののことです。
例えば、半透明の赤 RGBA=(1, 0, 0, 0.5)
は、RGBA_pre=(0.5, 0, 0, 0.5)
といった感じに、事前にアルファをかけ合わせておきます。
ちなみに、乗算済みでないほうはストレートアルファと呼ぶのが一般的なようです。
さて、通常のストレートアルファ時のアルファブレンドは基本的には以下のようになります。
c' = c_{\rm src} \cdot a_{\rm src} + c_{\rm dst} \cdot(1 - a_{\rm src})
\\
a' = a_{\rm src} + a_{\rm dst} \cdot(1 - a_{\rm src})
対して、乗算済みアルファの場合は以下のように。
c' = c_{\rm src} + c_{\rm dst} \cdot(1 - a_{\rm src})
\\
a' = a_{\rm src} + a_{\rm dst} \cdot(1 - a_{\rm src})
$a_{\rm src}$ の掛け算が減っている分、(今回の話とは関係なく)描画が効率的になる可能性があります。
まあ、実際には環境によってほぼ変わらなかったりしますが、とりあえず理屈の上では。
さて、ここで加算合成の式も見ておきましょうか。
c' = c_{\rm src} + c_{\rm dst}
\\
a' = a_{\rm src} + a_{\rm dst}
これを事前乗算済みアルファの式と見比べてみると、
$a_{\rm src} = 0$ と置けば全く同じ式になることがわかります。
つまり、フラグメントシェーダからの出力の際、アルファ値を0
に置き換えるだけで、
乗算済みアルファブレンドは加算合成に出来る、というわけです。
WebGL での例
WebGL の出力先となるキャンバス(メインのフレームバッファ)は、デフォルトで事前乗算済みアルファです。
あえて明示的に指定するときは以下のような感じに。
const gl = canvas.getContext('webgl', { premultipliedAlpha: true });
テクスチャに関しては、pixelStorei
で指定してやると、ピクセルデータを転送する際に事前乗算済みに変換してくれます。
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
話を簡単にするため、入力データはインスタンシング等を使わず単純な頂点リストにします。
変換行列なども無しで、正規化済みのデータを設定するものとしましょうか。
各頂点には属性として、二次元上の位置(position
)、UV座標(texcoord
) に加えて、
加算合成度合い(additive
, 0から1)を設定します。
バーテックスシェーダはこんな感じ。単に受け渡しているだけです。
attribute vec2 position;
attribute vec2 texcoord;
attribute float additive;
varying vec2 vTexcoord;
varying float vAdditive;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
vTexcoord = texcoord;
vAdditive = additive;
}
で、フラグメントシェーダはこう。
precision mediump float;
uniform sampler2D texture;
varying vec2 vTexcoord;
varying float vAdditive;
void main() {
vec4 color = texture2D(texture, vTexcoord);
color.a *= 1.0 - vAdditive;
gl_FragColor = color;
}
加算度が最大の時、出力アルファを 0 にすればよいので、テクスチャフェッチの後、
アルファに 1 - vAdditive
を掛け算しています。
描画時のブレンドモードは、式の通りに乗算済みアルファブレンド用のものを設定。
gl.enable(gl.BLEND);
gl.blendEquation(gl.FUNC_ADD);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
さて、後は頂点データを送り込むだけです。
加算合成したいときは、additive
を 1
に。アルファブレンドの時は 0
に。
一連のコードは CodePen の中身をご覧ください。
といいつつ、今回説明したかった部分以外は結構手抜きですが。許して。
実用上の効果
サンプルもそうなっていますが、基本的に効果が大きいのは2D用のスプライトバッチ(一枚のテクスチャに画像を詰め込み、一つのバッファに頂点データ突っ込んで一気に描画)と併用したときです。3Dではあまり出番はない気がします。
2D 描画ではアルファ抜きを多用しますので、基本的に描画順序を好き勝手にはいじれません。
アルファブレンドと加算合成が混在している場合、レンダリングステートの変更で実装しようとすると、せっかくバッチ化しても複数回のドローコールに分ける必要があり、パフォーマンスに結構な影響が出ます。
具体的にはシューティングとかアクションゲームとか、たくさんのキャラクターやエフェクトが画面上に存在するような場合。
キャラクターはアルファブレンドで、でも炎やビームは加算合成で...とやっているときに、レンダリングステートの変更で対応してしまうと、表示スプライト数以上に、ドローコール数の増加・レンダリングステートの切り替えによって負荷が上がっていきます。
加算合成は常にアルファブレンドの後で、と言うような切り分けをすれば2分割で済みますが、その分表現方法が限られることになります。
今回の小技を使って一度のドローコールで済ませるようにすると、混在させたまま、描画コストはほぼスプライト数(より厳密にはピクセル数)にスケールするようになります。また、副次的にですが "50%加算合成" みたいな中途半端なこともできるようになるので、表現の幅もやや広がります(ちょっとだけ光ってる、みたいな感じにできます)。
また、今回 WebGL でサンプルを書きましたけど、プログラマブルシェーダを持つグラフィックスAPIならば、大抵のもので通用する小技かと思います。まあ Vulkan とか DirectX12 とかだと、レンダリングステートの変更コストががっつり下がっているという噂なので、あまり気にしなくてもよくなっているかもしれませんが。
後記
趣味で作ってる描画ライブラリの実装が遅々として進まないので、
やる気出すために少しでもアウトプットしておこうと思ったものの、
どの辺の話が役に立つのか読めなかったので、
とりあえずネタになってなさそうな部分を攻めてみました。
そして ですます調 と である調 で毎度悩む...。