続・Cocos2d-xの輪郭線とか影回りを実用的に魔改造してみた

 以前の記事に引き続き。

 以前、「輪郭線が iOSAndroid にしか対応していなくて云々」というような理由で GLSL を用いた輪郭線表示を行なうようにしましたが、これを Android で実行してみると機種によってはこうなります。

error C5013: profile does not support "for" statements and "for" could not be unrolled.

 つまり、GPU だかドライバだかが GLSL のforループ(もちろんwhileと、ついでにswitchにも)には対応していない、というエラーです。はて困りました。

 こんな時、どうすればいいかは簡単です。
 forループが使えないなら、 ループを展開した GLSL を動的生成しちゃえばいいじゃない

というわけでループを展開してみた……ところ

 以前の記事でループを使っていた部分は2ヵ所。影を複数つけられるようにしたので影ごとにループを回す部分と、それぞれのピクセルが影のぼかし部分や輪郭線の部分に入っていないか、周囲のテクスチャのピクセルを探す部分です。
 前者は最大数がkCCDecorationLabelMaxShadowCountで決められていたので簡単ですが、後者が困難。というのも、影の大きさ次第でループ範囲が変わるので、ループ範囲を決め打ちできないのです。
 なら、描画する度に影のサイズをチェックして、影が前回よりも大きくなっていたらループ範囲を増やせばよいのですが、実際にやってみると極めて重大な問題が持ち上がりました。シェーダの実行が、恐ろしく重いのです……具体的にはテクスチャ1枚に分単位で時間がかかります。

 理由を調べていったところ、どうやら GLSL のif文は、条件を満たすかどうかにかかわらず、常に分岐後の式を評価して、後で結果を捨てる模様。そりゃあ不要な処理が大量に増えるわけで、遅くなるのも当たり前です。
 なので、折角コードを動的生成するのだから、全ての影についてなるべく必要最低限のみを使うようにしてしまいましょう。少々面倒ですが、毎回影の数とサイズをチェックして、一度使った事のあるパターンであればキャッシュしたプログラムを使い、そうでなければ新たにプログラムを生成してキャッシュに突っ込む感じです。

例えばこんな感じに

 以前と比べると、uniform変数の種類や意味が変わっていますが、説明は割愛します。今回の記事とは無関係なところではありますが、除算よりも高速な乗算を使うため、texGeom.xyを逆数で入れるようにしてあるところは注意です。  あと、なんかよくわからなかったαが2乗される問題がまだ解決できていないため、gl_FragColor = color;ではなくgl_FragColor = vec4(color.rgb, sqrt(color.a));になっています。

メインパーツ

 まずは、メインとなるGLSLパーツ。

uniform sampler2D texture; /* ベース・テクスチャ */
uniform vec4 tintColor;    /* 文字色 */
uniform vec4 texGeom;      /* vec4(1/texture.width, 1/texture.height, texture.x, texture.y) */
uniform vec4 shadowOuterColors[$OUTER_SHADOW_COUNT$]; /* 外側の影の色 */
uniform vec4 shadowOuterShapes[$OUTER_SHADOW_COUNT$]; /* 外側の影の形状 vec4(dx, dy, extent, blur) */
uniform vec4 shadowInnerColors[$INNER_SHADOW_COUNT$]; /* 内側の影の色 */
uniform vec4 shadowInnerShapes[$INNER_SHADOW_COUNT$]; /* 内側の影の形状 vec4(dx, dy, extent, blur) */

/* ベース・テクスチャのアルファを求める。アンチエイリアスのかかっている場所では 0 と 1 の間を取る */
float baseAlpha(vec2 coord)
{
  return texture2D(texture, coord * texGeom.xy).a;
}

/* (-∞, edge0] の区間で 0 、(edge0, edge1) の区間で 0〜1 、[edge1, +∞) の区間で 1 となるような関数 */
float linearstep(float edge0, float edge1, float x)
{
  return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); /* 使用法的に、常にedge0<edge1の保証有り */
}

/* 下の色と上の色をアルファつきで混ぜる */
vec4 mergeOpaqueColors(vec4 under, vec4 over)
{
  if (over.a == 0.0) return under; /* 上の色は透明 */

  float under_a = under.a * (-over.a) + under.a;
  float a = over.a + under_a;
  return vec4((over.rgb * over.a + (under.rgb * under_a)) / a, a);
}

/* 文字の外側のピクセルについて、文字輪郭までの距離を求める */
float getOuterDist(vec2 pos, float rMin, float len)
{
  float a = baseAlpha(pos);
  /* 以下、if (a != 0) return min(rMin, len - a); else return rMin; を高速化したもの */
  return mix(rMin, min(rMin, len - a), step(0.0001, a)); /* 0 < 0.0001 < 1/255 */
}

/* 文字の内側のピクセルについて、文字輪郭までの距離を求める */
float getInnerDist(vec2 pos, float rMin, float len)
{
  float a = 1.0 - baseAlpha(pos); /* 内側ならば、アルファを反転 */
  /* 以下、if (a != 0) return min(rMin, len - a); else return rMin; を高速化したもの */
  return mix(rMin, min(rMin, len - a), step(0.0001, a)); /* 0 < 0.0001 < 1/255 */
}

$OUTER_SHADOW_FUNCS$

$INNER_SHADOW_FUNCS$

void main(void)
{
  /* 外側の影を描画 */
  vec4 color = vec4(0.0, 0.0, 0.0, 0.0);
$DRAW_OUTER$
  /* フォント色で描画 */
  float face_a = baseAlpha(gl_FragCoord.xy - texGeom.zw);
  if (0.0 != face_a) {
    vec4 innerColor = tintColor;

    /* 文字の内部なので、内側の影を描画 */
$DRAW_INNER$
    /* アンチエイリアス分を考慮しつつ、外側の影とフォント色を合成 */
    color = mergeOpaqueColors(color, vec4(innerColor.rgb, innerColor.a * face_a));
  }

  gl_FragColor = color;
}

 ここで、$〜$で囲まれた部分は、動的生成のためのプレースホルダです。$〜$でなくとも、好きなようにプレースホルダを作ればよいのですが。
 それぞれ、

  • $OUTER_SHADOW_COUNT$: 文字の外側に発生させる影の数
  • $INNER_SHADOW_COUNT$: 文字の内側に発生させる影の数
  • $OUTER_SHADOW_FUNCS$: 動的生成される、外側の影の計算用の関数
  • $INNER_SHADOW_FUNCS$: 動的生成される、内側の影の計算用の関数
  • $DRAW_OUTER$: 動的生成される、外側の影色の合成処理
  • $DRAW_INNER$: 動的生成される、内側の影色の合成処理

を意味します。$OUTER_SHADOW_COUNT$$INNER_SHADOW_COUNT$は自明なので、重要なのはその他4つです。

影の合成処理

 次に、$DRAW_OUTER$$DRAW_INNER$を見てゆきましょう。
 と言っても、実のところ大したことはしていなかったり。

$DRAW_OUTER$

  color = mergeOuterShadow$i$(color); /* $i$番目の外側の影 */

$DRAW_INNER$

    innerColor = mergeInnerShadow$i$(innerColor); /* $i$番目の内側の影 */

 ……はい。本当に全く大したことありませんね。これを外側/内側の影の数だけ繰り返しながら、それぞれについて$i$を影のインデックスに書き換えてやればいい話です。具体的には、例えばこんな形に埋め込まれるわけですね。

void main(void)
{
  /* 外側の影を描画 */
  vec4 color = vec4(0.0, 0.0, 0.0, 0.0);
  color = mergeOuterShadow0(color); /* 0番目の外側の影 */
  color = mergeOuterShadow1(color); /* 1番目の外側の影 */

  /* フォント色で描画 */
  float face_a = baseAlpha(gl_FragCoord.xy - texGeom.zw);
  if (0.0 != face_a) {
    vec4 innerColor = tintColor;

    /* 文字の内部なので、内側の影を描画 */
    innerColor = mergeInnerShadow0(innerColor); /* 0番目の内側の影 */

    /* アンチエイリアス分を考慮しつつ、外側の影とフォント色を合成 */
    color = mergeOpaqueColors(color, vec4(innerColor.rgb, innerColor.a * face_a));
  }

  gl_FragColor = vec4(color.rgb, sqrt(color.a));
}

 そして、このmergeOuterShadow$i$()mergeInnerShadow$i$()をどう動的生成してやるか、というのが今回の話のキモになります。

影の計算関数の動的生成

 関数としては Inner/Outer で別れていますが、やっている事はほぼ同じですので、テンプレートは共通の1種類で済みます。
 プレースホルダ$INNER_OUTER$に、"Inner" または "Outer" が入るイメージ。$CHECK_AROUND_i$が2つめのループである、周囲の影を取得してくる部分です。$CHECK_AROUND_i$は関数ごとに別々の内容が入ります。

/* $i$番目の影の色を追加する */
vec4 merge$INNER_OUTER$Shadow$i$(vec4 lastColor)
{
  vec4 shape = shadow$INNER_OUTER$Shapes[$i$];

  /* 参照するベース・テクスチャ上の位置 */
  vec2 center = gl_FragCoord.xy - texGeom.zw - shape.xy;

  float rMin = shape.z; /* rMin=文字との最短距離 */
  /* 太くした部分の範囲内かどうかを確認するために、centerの周辺のテクスチャ上のピクセルを確認 */
$CHECK_AROUND_i$

  if (shape.z <= rMin) return lastColor; /* 影の外側 */

  /* ぼやけを考慮した影の濃さを求める */
  vec4 col = vec4(shadow$INNER_OUTER$Colors[$i$].rgb, shadow$INNER_OUTER$Colors[$i$].a * linearstep(-shape.z, 1.0 - shape.w, -rMin)); /* linearstep(-a, -b, -x) == 1.0 - linearstep(b, a, x) */

  /* 現在色と合成 */
  return mergeOpaqueColors(lastColor, col);
}

 $CHECK_AROUND_i$のテンプレートは、こんな感じです:

  rMin = get$INNER_OUTER$Dist(center + vec2($x$.0, $y$.0), rMin, $length$);

 ここで、$x$, $y$は、半径=shadow$INNER_OUTER$Shapes[$i$].zの円の内部に入る全てのピクセル$length$は中心からそのピクセルの間の距離となります(length(vec2($x$, $y$))と一致)。
 最終的に、merge$INNER_OUTER$Shadow$i$は例えば次のような形に実体化される事になります。

/* 0番目の影の色を追加する */
vec4 mergeOuterShadow0(vec4 lastColor)
{
  vec4 shape = shadowOuterShapes[0];

  /* 参照するベース・テクスチャ上の位置 */
  vec2 center = gl_FragCoord.xy - texGeom.zw - shape.xy;

  float rMin = shape.z; /* rMin=文字との最短距離 */
  /* 太くした部分の範囲内かどうかを確認するために、centerの周辺のテクスチャ上のピクセルを確認 */
  rMin = getOuterDist(center + vec2(0.0, -1.0), rMin, 1.000000);
  rMin = getOuterDist(center + vec2(-1.0, 0.0), rMin, 1.000000);
  rMin = getOuterDist(center + vec2(0.0, 0.0), rMin, 0.000000);
  rMin = getOuterDist(center + vec2(1.0, 0.0), rMin, 1.000000);
  rMin = getOuterDist(center + vec2(0.0, 1.0), rMin, 1.000000);

  if (shape.z <= rMin) return lastColor; /* 影の外側 */

  /* ぼやけを考慮した影の濃さを求める */
  vec4 col = vec4(shadowOuterColors[0].rgb, shadowOuterColors[0].a * linearstep(-shape.z, 1.0 - shape.w, -rMin)); /* linearstep(-a, -b, -x) == 1.0 - linearstep(b, a, x) */

  /* 現在色と合成 */
  return mergeOpaqueColors(lastColor, col);
}

実行速度の比較

 かなり大きめの影を取って以前のコード(をもう少し改良して速くしたもの)と比べると、次のような差がありました。

Device 旧コード 今回コード
iPod touch(第5世代) 970ms 14706ms
Nexus 7 GLSLコンパイルエラー 1360ms
Galaxy SII(SC-02C)※フォント小 0ms 0ms
Galaxy SII(SC-02C)※フォント大 0ms(表示崩れ) アプリ強制終了

 ……Galaxy SII の当てにならなさが酷い。表示崩れの時はGL_OUT_OF_MEMORY​が返ってきてるので、どうやらテクスチャメモリ確保に失敗している模様です。フォントと影の大きさが大きすぎたんでしょうね。それにしても、確保失敗したのにそのままnullptrも返さず処理を続行するcocos2d::CCRenderTexture::create()さんが漢らしすぎる。他のcreate()系関数のnullptr返す仕様と合わさってればいいのに。

 ともあれ、上記の結果から、原則としてfor版を使いつつ、for版が使えない時にだけ非for版を使ってやればよさそうだとわかります。
 実はこの部分にもこっそりと妙な罠が潜んでいるのですが、これについてはまた別の記事にて。

この本、おすすめです。