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

 つまり、CCLabelTTF::enableStroke やら CCLabelTTF::enableShadow のこと。Android ではどうだか確認していませんが、少なくとも iOS ではこんな感じの問題が起こりました:

(1) iOS 7 で非推奨になった機能を使っているので、iOS 7 では輪郭線色が正しく出ない
(2) 輪郭線を内側と外側にオフセットして輪郭線色を作っているので、大きめに輪郭線をつけると文字が潰れて汚い
(3) 輪郭線を太くした時、変な風にスパイクが飛び出る
(4) なんかblurが正しく動作してないっぽい?
(5) 太い輪郭線+影にすると、前の文字の上に影が重なる
(6) さらによく見ると、影同士も重なって大変なことになっている

 しかもソースをよく読むと、

(7) 輪郭線や影が iOSAndroid にしか対応していない

 ……使い物にならないって言いませんかそれ*1

 上記の問題のうち、(1)だけは CCImage.mm を修正することで簡単に直ります。が、他の問題はそもそも OS の輪郭/影つき描画機能を使おうとするせいで起こるわけですから、一体どうすればよいものやら……。

 そうだ! ならば OS の機能を使うのをやめてしまおう!

新しい CCLabelTTF の作り方

 というわけで探すと出てくるのが、以下のような記事でした。

 http://taoru.hateblo.jp/entry/20130222/1361517688

 元ネタであろう公式フォーラムの書き込みはこちら。

 http://www.cocos2d-iphone.org/forums/topic/outlinestroke-on-cclabelttf/

 本来の文字を中心にして何度も重ねて書く事で太くすればいいじゃん、という発想。
 確かにある程度問題は解決するのですが、極めてイケてない。ものすごく太い輪郭線を指定した時、この方法では酷い事になるに違いありません。しかも、この方法で修正できるのは輪郭線の問題だけで、影の方は全く手つかずどころか、この方法で半透明の影を描こうとすると、重なり方が尋常ではないことに。

 というわけで、今までにない斬新かつ完璧な方法で影と輪郭線を作ってやりたいところですが、折角そんな輪郭線/影の仕組みを作るなら、今よりもっと多機能な輪郭線/影の作り方をしてもいいんですよね?
 例えば、影は黒限定でなくとも、任意の色で作れるようにしたっていいわけです。輪郭線だって半透明を使いたい。ついでに輪郭線もぼやけさせて……。

 ここまで来ると、影と輪郭線の間にはほとんど差がないことがおわかりでしょう。
 必要なのは、「移動量」「太くする量」「ぼやけ量」「色(RGBA)」「内側か外側かのフラグ」を持った「超影」のみでよいのです。今まで輪郭線と呼んでいたものは「移動量=0」「ぼやけ量=0」の「超影」であり、影と呼んでいたものは「太くする量=0」「色=(0, 0, 0, α)」の「超影」である、ということ。
 だったら、新たに作る『CCLabelTTF っぽいもの』はこの「超影」を複数指定できるようにするだけで事足りるわけですね。

というわけで作ってみる

 それでは実際に、この新しいクラス、CCDecorationLabelTTF を実装してみることにしましょう。具体的には、GLSL を用いて各ピクセルがどの影に含まれるかを確認し、適切に合成してやるだけです。だけですって言うのもアレなんですが。
 CCLabelTTF をコピーしてきて、輪郭線と影関連のところを削除して、updateTexture()を魔改造します。あ、ccFontDefinition 関連のところも要らないので消しちゃって下さい。もし必要なら、新しい影の仕組みに基づいた ccDecorationFontDefinition か何かを定義してやるとよいかと思います。

まずは、影の定義

 とりあえず、こんな感じにしておけば十分でしょうか。
 C ならともかく、何で C++ で typedef による構造体宣言を使ってるのかは謎ですが、まあ Cocos2d の流儀に乗っ取っておくとします。

typedef struct _ccFontShadowEx
{
  // shadow is not enabled by default
  _ccFontShadowEx()
    : m_shadowEnabled(false)
    , m_shadowSizeIsInFontSize(true)
    , m_shadowOffset(0, 0)
    , m_shadowBlur(0)
    , m_shadowColor({0, 0, 0, 128})
  {}

  // この影を使用するかどうか(既定値:false)
  bool      m_shadowEnabled = false;
  // m_shadowOffset/m_shadowExtent をフォントサイズに対する比率として扱うかどうか(既定値:false)
  bool      m_shadowSizeIsInFontSize = false;
  // 影が文字の内部に生じるかどうか(既定値:false)
  bool      m_shadowInset = false;
  // 影の発生位置(既定値:{0, 0})
  CCSize    m_shadowOffset = CCSizeZero;
  // 影を太くする量(既定値:0)
  float     m_shadowExtent = 0;
  // m_shadowExtentのうち、ぼやけをかける部分の比率を0〜1で指定(既定値:0)
  float     m_shadowBlur = 0;
  // 影の色
  ccColor4B m_shadowColor = {0, 0, 0, 128};
} ccFontShadowEx;

 m_shadowSizeIsInFontSize の追加は趣味。たぶん、あった方が便利だと思います。

updateTexture() でやること

 いくら影と輪郭は自前で描くようにしたからといって、文字列描画まで自前でやるのは愚かの極みです。CCLabelTTF でも普通の文字を描く上では何も問題がなかったのですから、そこだけは有効活用させて貰いましょう。
 具体的には、影と輪郭のない ccFontDefinition を構築して、CCLabelTTF と同様にテクスチャーを作ります。これをベースに影のついたテクスチャーを作ったら、後はやはり CCLabelTTF と同じように setTexture() と setTextureRect() してやるだけ。
 あと、CCLabelTTF では明示的に release() していた CCTexture2D は、autorelease() にするようにしておきます。CCRenderTexture で作成した影つきテクスチャーの方も autorelease なので、合わせておいた方が無難です(こうすると、影つきテクスチャーが作成できない場合は元のテクスチャーで代用する、という処理がやりやすくなる)。

 そんなわけでこんな感じ。
 なお、影の最大数は kCCDecorationLabelMaxShadowCount = 4 という定数で与えています。実用上は 4 で問題ないとは思うのですが、必要でしたらもっと増やしても構いません。16 くらいならたぶん余裕です。

bool CCDecorationLabelTTF::updateTexture()
{
  CCTexture2D *tex = new CCTexture2D();
  tex->autorelease();

  // ベースとなるテクスチャーを作るための構造体
  ccFontDefinition texDef;
  texDef.m_alignment = m_alignment;
  texDef.m_vertAlignment = m_vertAlignment;
  texDef.m_dimensions = CC_SIZE_POINTS_TO_PIXELS(m_dimensions);
  texDef.m_fontFillColor = ccWHITE;
  texDef.m_fontName = *m_fontName;
  texDef.m_fontSize = (int)(m_fontSize * CC_CONTENT_SCALE_FACTOR());
  texDef.m_shadow.m_shadowEnabled = false;
  texDef.m_stroke.m_strokeEnabled = false;

  // 有効な影を全て取得&影のサイズを取得
  float top = 0, bottom = 0, left = 0, right = 0;
  for (const auto &shadow : m_shadows) {
    if (shadow.m_shadowEnabled && !shadow.m_shadowInset) {
      float ratio = (shadow.m_shadowSizeIsInFontSize ? texDef.m_fontSize : CC_CONTENT_SCALE_FACTOR());
      left   = std::max(left  , (shadow.m_shadowExtent - shadow.m_shadowOffset.width ) * ratio);
      right  = std::max(right , (shadow.m_shadowExtent + shadow.m_shadowOffset.width ) * ratio);
      top    = std::max(top   , (shadow.m_shadowExtent + shadow.m_shadowOffset.height) * ratio);
      bottom = std::max(bottom, (shadow.m_shadowExtent - shadow.m_shadowOffset.height) * ratio);
    }
  }
  // texDef.m_dimensionsを修正
  texDef.m_dimensions.width  -= left + right;
  texDef.m_dimensions.height -= top + bottom;
  if (texDef.m_dimensions.width  < 0) texDef.m_dimensions.width  = 0;
  if (texDef.m_dimensions.height < 0) texDef.m_dimensions.height = 0;

  // ベース・テクスチャー生成
#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID) || (CC_TARGET_PLATFORM == CC_PLATFORM_IOS)
  tex->initWithString( m_text.c_str(), &texDef );
#else
  tex->initWithString( m_text.c_str(),
                      texDef.m_fontName.c_str(),
                      texDef.m_fontSize * CC_CONTENT_SCALE_FACTOR(),
                      texDef.m_dimensions,
                      texDef.m_alignment,
                      texDef.m_vertAlignment);
#endif

  // 影つきテクスチャーを作る
  CCTexture2D *shadowedTexture = createShadowedTexture(tex, left, right, top, bottom);
  this->setTexture(shadowedTexture);

  // set the size in the sprite
  CCRect rect = CCRectZero;
  rect.size   = m_pobTexture->getContentSize();
  this->setTextureRect(rect);

  //ok
  return true;
}
影つきテクスチャーの作り方

 まずは、影つきテクスチャーのサイズを求めます。有効な全ての影に対して m_shadowOffset と m_shadowExtent を見ることで、本来の文字から上下左右にどれだけはみ出すかがわかりますね。
 あ…… updateTexture() の時の m_dimensions も、このサイズだけ小さくしておきましょう。そうしないと setDimensions() したサイズよりも、影まで含めた描画サイズが大きくなってしまうので。

 この部分のソースは、だいたいこんな感じになっています。
 ただし、TEXTURE_UNIT_ID = 0 。

CCTexture2D *CCDecorationLabelTTF::createShadowedTexture(CCTexture2D *tex, float left, float right, float top, float bottom)
{
  // 各影の情報を設定する
  GLfloat shadowColor[kCCDecorationLabelMaxShadowCount*4];
  GLfloat shadowShape[kCCDecorationLabelMaxShadowCount*4];
  GLfloat isInset[kCCDecorationLabelMaxShadowCount];

  int index = 0;
  for (const auto shadow : m_shadows) {
    if (!shadow.m_shadowEnabled) continue;

    shadowColor[index*4+0] = shadow.m_shadowColor.r / 255.0f;
    shadowColor[index*4+1] = shadow.m_shadowColor.g / 255.0f;
    shadowColor[index*4+2] = shadow.m_shadowColor.b / 255.0f;
    shadowColor[index*4+3] = shadow.m_shadowColor.a / 255.0f;

    float ratio = (shadow.m_shadowSizeIsInFontSize ? (float)(int)(m_fontSize * CC_CONTENT_SCALE_FACTOR()) : CC_CONTENT_SCALE_FACTOR());

    float extent = std::max(0.0f, shadow.m_shadowExtent);
    shadowShape[index*4+0] = ratio * shadow.m_shadowOffset.width;
    shadowShape[index*4+1] =-ratio * shadow.m_shadowOffset.height;
    shadowShape[index*4+2] = ratio * extent;
    shadowShape[index*4+3] = ratio * extent * std::max(0.0, std::min(1.0, 1.0 - shadow.m_shadowBlur));

    isInset[index] = shadow.m_shadowInset ? 1.0 : 0.0;

    ++index;
  }

  auto size = tex->getContentSizeInPixels();
  size.width  += left + right;
  size.height += top + bottom;
  size.width  = std::ceil(size.width );
  size.height = std::ceil(size.height);

  int sizeX = (int)size.width, sizeY = (int)size.height;
  if (sizeX <= 0 || sizeY <= 0) return tex; // サイズがゼロ

  // CCRenderTexture にピクセル単位でサイズを指定するため、一時的に setContentScaleFactor(1.0) とする
  const float lastScaleFactor = CCDirector::sharedDirector()->getContentScaleFactor();
  CCDirector::sharedDirector()->setContentScaleFactor(1.0);
  CCRenderTexture *renderTexture = CCRenderTexture::create(sizeX, sizeY);
  CCDirector::sharedDirector()->setContentScaleFactor(lastScaleFactor); // もう戻してよい
  { // このスコープの間だけ renderTexture に書き込むような処理
    Renderer renderer(renderTexture, m_fontFillColor, index, shadowColor, shadowShape, isInset, (GLfloat)left, (GLfloat)bottom, (GLfloat)right, (GLfloat)top);

    glActiveTexture(GL_TEXTURE0 + TEXTURE_UNIT_ID);
    glBindTexture(GL_TEXTURE_2D, tex->getName());

    // 描画
    GLfloat vertices[] = { -1, -1, 1, -1, -1, 1, 1, 1 }; // (-1, -1) - (1, 1) の範囲を描画すると、RenderTexture 全体を描画できる
    glVertexAttribPointer(kCCVertexAttrib_Position, 2, GL_FLOAT, GL_FALSE, 0, vertices);
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
  }

  return renderTexture->getSprite()->getTexture();
}

途中に出てくる Renderer というのが、今回の処理の本体です。この内部で、元となる文字列テクスチャーを加工して影をつけてやりましょう。

Renderer の中身

 コンストラクタ内で CCRenderTexture を begin() してシェーダプログラムを準備してパラメータを設定し、デストラクタで CCRenderTexture を end() するだけ。おっと、CCScrollView の中に入れた時にも正しく処理ができるよう、SCISSOR_TEST をオフにしておかねばなりません。
 OpenGL 直叩きではシェーダーの準備までがとてつもなく面倒ですが、Cocos2d-x だと文字列を渡すだけで済むのでいいですね。

class Renderer
{
 public :
  Renderer(CCRenderTexture *renderer, const ccColor4B &color, GLint shadowCount,
           GLfloat *shadowColors, GLfloat *shadowShapes, GLfloat *isInset,
           GLfloat left, GLfloat bottom, GLfloat right, GLfloat top);
  ~Renderer();

private :
  GLboolean m_scissor ;
  CCRenderTexture *m_renderer;

  // シングルトン
  static CCGLProgram *s_program;

  static CCGLProgram *sharedRenderProgram();
};

CCGLProgram *Renderer::s_program = nullptr;

Renderer::Renderer(CCRenderTexture *renderer, const ccColor4B &color, GLint shadowCount,
                   GLfloat *shadowColors, GLfloat *shadowShapes, GLfloat *isInset,
                   GLfloat left, GLfloat bottom, GLfloat right, GLfloat top)
  : m_renderer(renderer), m_scissor(glIsEnabled(GL_SCISSOR_TEST))
{
  // SCISSOR_TESTを一時的に除去
  if (m_scissor != GL_FALSE) glDisable(GL_SCISSOR_TEST);

  // CCRenderTexture への描画開始
  m_renderer->beginWithClear(0, 0, 0, 0);

  // シェーダプログラムを有効化
  CCGLProgram *prog = sharedRenderProgram();
  prog->use();

  // 属性の追加
#define UNIFORM(name) prog->getUniformLocationForName(name)
  prog->setUniformLocationWith1i(UNIFORM("texture"), TEXTURE_UNIT_ID);
  prog->setUniformLocationWith4f(UNIFORM("tintColor"), color.r / 255.f, color.g / 255.f, color.b / 255.f, color.a / 255.f);
  const auto &size = m_renderer->getSprite()->getTexture()->getContentSizeInPixels();
  float w = size.width - left - right, h = size.height - top - bottom; // 元となるテクスチャーのサイズ
  GLfloat array[] = {w, h, left, top};
  prog->setUniformLocationWith4fv(UNIFORM("texGeom"), array, 1);
  prog->setUniformLocationWith1i(UNIFORM("shadowCount"), shadowCount);
  prog->setUniformLocationWith4fv(UNIFORM("shadowColors"), shadowColors, kCCDecorationLabelMaxShadowCount);
  prog->setUniformLocationWith4fv(UNIFORM("shadowShapes"), shadowShapes, kCCDecorationLabelMaxShadowCount);
  glUniform1fv(UNIFORM("isInset"), (GLsizei)kCCDecorationLabelMaxShadowCount, isInset); // 何故か CCGLProgram には用意されていないので、ここだけ OpenGL 直叩き
#undef UNIFORM
}

Renderer::~Renderer()
{
  // CCRenderTexture への描画終了
  m_renderer->end();

  // SCISSOR_TEST を元に戻す
  if (m_scissor != GL_FALSE) glEnable(GL_SCISSOR_TEST);
}

CCGLProgram *Renderer::sharedRenderProgram()
{
  if (!s_program) {
    const GLchar *vsh = ...; // バーテックスシェーダ
    const GLchar *fsh = ...; // フラグメントシェーダ

    // シングルトンの準備
    s_program = new CCGLProgram();
    if (!s_program->initWithVertexShaderByteArray(vsh, fsh)) {
      CC_SAFE_DELETE(prog);
      return nullptr;
    }

    s_program->link();
  }

  return s_program;
}

 ここまで来れば、後は、適切なシェーダプログラムを書いてやれば終わり。

シェーダーの書き方

 こんな感じになりました。

バーテックスシェーダ:

attribute vec4 position;

void main(void)
{
  gl_Position = position; /* C++ の方で描画範囲を (-1, -1) - (1, 1) にしてあるので、そのまま出力 */
}

フラグメントシェーダ:

const int kCCDecorationLabelMaxShadowCount = 4; /* C++ 側と同じ値を設定 */
uniform sampler2D texture; /* ベース・テクスチャー */
uniform vec4 tintColor;    /* 文字色 */
uniform vec4 texGeom;      /* vec4(texture.width, texture.height, texture.x, texture.y) */
uniform int shadowCount;   /* 使用する影の色 */
uniform vec4 shadowColors[kCCDecorationLabelMaxShadowCount]; /* 影の色×shadowCount */
uniform vec4 shadowShapes[kCCDecorationLabelMaxShadowCount]; /* (dx, dy, extent, blur)×shadowCount */
uniform float isInset[kCCDecorationLabelMaxShadowCount];     /* 内側かどうかのフラグ */

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

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

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

/* i番目の影の色を追加する */
vec4 mergeShadow(vec4 lastColor, int i, bool inner)
{
  /* 参照するベース・テクスチャー上の位置 */
  vec2 center = gl_FragCoord.xy - texGeom.zw - shadowShapes[i].xy;

  float r1 = shadowShapes[i].w;       /* ぼやけの開始半径 */
  float r0 = shadowShapes[i].z + 1.0; /* ぼやけの終了半径 */

  float rMin = r0; /* 文字との最短距離 */
  /* 太くした部分の範囲内かどうかを確認するために、centerの周辺のテクスチャ上のピクセルを確認 */
  float maxY = floor(r0 - 1.0), minY = -maxY;
  for (float y = minY; y <= maxY; y += 1.0) {
    float r_1 = rMin - 1.0;
    float maxX = floor(sqrt(r_1*r_1 - y*y)), minX = -maxX;
    for (float x = minX; x <= maxX; x += 1.0) {
      vec2 xy = vec2(x, y);
      float a = baseAlpha(center + xy);
      if (inner) a = 1.0 - a; /* 内側ならば、アルファを反転 */
      if (a == 0.0) continue; /* アルファが0ならば考慮に入れない */

      float r = length(xy) + 1.0 - a; /* ピクセルの距離+アンチエイリアス分を真の距離とみなす */
      if (r < rMin) {
        /* 最短距離を更新 */
        rMin = r;
        /* チェックの必要な範囲を狭める */
        maxY = floor(r - 1.0);
        maxX = abs(x);
      }
    }
  }

  if (r0 <= rMin) return lastColor; /* 影の外側 */

  /* ぼやけを考慮した影の濃さを求める */
  vec4 col = shadowColors[i];
  col.a *= 1.0 - smoothstep(r1, r0, rMin);

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

void main(void)
{
  /* 外側の影を描画 */
  vec4 color = vec4(0.0, 0.0, 0.0, 0.0);
  for (int i = 0; i < shadowCount; ++i) {
    if (isInset[i] == 0.0) color = mergeShadow(color, i, false);
  }

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

    /* 文字の内部なので、内側の影を描画 */
    for (int i = 0; i < shadowCount; ++i) {
      if (isInset[i] != 0.0) innerColor = mergeShadow(innerColor, i, true);
    }

    /* アンチエイリアス分を考慮 */
    innerColor.a *= face_a;

    /* 外側の影とフォント色を合成 */
    color = mergeOpaqueColors(color, innerColor);
  }

  /* 最終出力 */
  gl_FragColor = color;
}

肝心の結果は……?

 こんな感じです。
 iOS シミュレータではレンダリングに妙に時間がかかるようになりましたが、実機だとそんな事はありません。

f:id:cflat-inc:20131217122130p:plain

 あれ……アルファが2乗されてる……?

 フラグメントシェーダーのバグかと思って調べてみたのですが、フラグメントシェーダーを

void main() {
  gl_FragColor = vec4(1.0,0.0,0.0,0.5);
}

とだけする場合にも α=64 となるので、どうやらシェーダープログラムのバグではない模様。
 たぶん、どこかで妙な設定が行なわれてるんだと思いますが、今のところ原因は不明。情報求む。

 とりあえず、gl_FragColor に color を設定する前に color.a = sqrt(color.a); としてやれば、対症療法にはなる……のかな*2
f:id:cflat-inc:20131217122130p:plain

[asin:4839946973:detail]

*1:Cocos2d-x 3.0 alphaでも直ってませんでした

*2:もちろん、決して良い解決法ではない