iOS・macOS

レイヤーの不透明度が効いていなかったのでUIImageのdraw(at:)を引数付きに変えた

  • iOS
  • Swift
  • CoreGraphics
  • SpriteFrame

ピクセルアート制作アプリSpriteFrameで、レイヤーの不透明度スライダーが表示・書き出し・サムネイルのどの合成結果にも反映されていないバグを見つけました。原因はUIImage.draw(at:)CGContext側で設定したアルファ値を無視して常に不透明で描画することで、draw(at:blendMode:alpha:)というパラメーター付きのAPIに切り替えて直しました。

SpriteFramePixel Art Studio for iPadDraw sprites, stack frames, ship sheets. A pixel-art studio built for iPad — free to download, one-time Pro upgrade, no subscription.

レイヤー統合機能のテストで見つかった

SpriteFrameは複数レイヤーを重ねてドット絵を描けるアプリで、レイヤーごとに不透明度とブレンドモードを設定できます。レイヤーを1枚に統合する「下と結合」機能を実装した際、半透明レイヤーを結合したときにピクセル値が意図どおりになっているかを検証するテストを新しく書きました。

不透明度0.5の青レイヤーを、不透明な赤レイヤーの上に結合するテストを走らせたところ、結合後のピクセルは赤と混ざった色ではなく、完全に不透明な青(#0000FFFF)になっていました。既存のテストはレイヤー合成の出力サイズやnilチェックしか見ておらず、ピクセル値そのものを検証するテストがこれまで存在していなかったため、この不具合はずっと見逃されていました。

CGContextの状態はdraw(at:)に伝わらない

レイヤーを合成する処理は次のように書かれていました。

context.saveGState()
context.setAlpha(CGFloat(layer.data.opacity))
context.setBlendMode(layer.data.blendMode.cgBlendMode)

layerImage.draw(at: .zero)

context.restoreGState()

CGContextsetAlphasetBlendModeで不透明度とブレンドモードを設定してから、UIImageを描画する形です。一見自然な書き方に見えますが、UIImage.draw(at:)の公式ドキュメントには「このメソッドはCGBlendMode.normalブレンドモードで、常に不透明度100%で画像を描画する」と明記されています。引数を取らないこのバリエーションでは、事前にCGContextへ設定したアルファ値やブレンドモードは渡らず無視されます。

UIImageにはdraw(at:blendMode:alpha:)という、ブレンドモードとアルファをパラメーターとして直接受け取るバリエーションが用意されています。CGContextの状態に依存させず、この形で明示的に渡すのが正しい使い方です。

layerImage.draw(
  at: .zero,
  blendMode: layer.data.blendMode.cgBlendMode,
  alpha: CGFloat(layer.data.opacity)
)

パラメーターに渡す形に変えると、saveGStatesetAlphasetBlendModerestoreGStateもまとめて不要になります。CGContextの状態を経由する分岐がなくなるので、コードとしても短くなりました。

不透明度0.5の効果は、透過が分かるチェッカーボード背景の上に合成すると一目瞭然です。実際の回帰テストは不透明な赤レイヤーの上での混色を検証していますが、下図は不透明度の効果そのものを分かりやすくするための簡略化した例で、テストの色構成とは別のシナリオです。

不透明度0.5のレイヤー合成の比較。修正前は完全に不透明な青として合成され下の層が透けないが、修正後は背景のチェッカーボードが透けて見える

気づきにくい理由

このAPIが紛らわしいのは、CGContextの状態が全てのコンテキスト設定に無視されるわけではないためです。fillstrokeのようなCGContext自身の描画命令はsetAlphasetBlendModeの設定をそのまま反映します。UIImage.draw(at:)だけが「画像を貼り付ける」という別系統の操作で、コンテキストの状態を素通りして常に.normal・アルファ1.0で描画するという例外的な挙動を持っています。

同じCGContextを触っているという見た目のまとまりから、UIImageの描画も同じ状態を継承すると思い込みやすく、コンパイラも警告を出しません。実際に半透明のピクセルを目で確認しない限り気づけない類いのバグです。今回はテストが実測して初めて発覚しましたが、目視確認だけに頼っていたら「なんとなく薄く見える」程度の違和感で見過ごしていた可能性もあります。

表示・書き出し・サムネイル・結合のすべてに影響していた

この合成処理はレイヤーを1枚の画像にまとめる共通の入り口で、キャンバスの画面表示、PNG・GIFへの書き出し、サムネイル生成、そして今回のレイヤー結合のすべてが同じコードを通ります。つまり、非アクティブなレイヤーの不透明度スライダーを動かしても、表示上は反映されていませんでした。ブレンドモードも同じsaveGStateのスコープ内で設定していたコードで、こちらの検証は後述します。

この修正をリリースすると、これまで不透明度スライダーを使っていたユーザーの作品は、今回のアップデート以降で初めて指定どおりの半透明として表示されることになります。挙動が変わったことが伝わるよう、リリースノートでの案内も合わせて検討しています。

サイズだけでなくピクセル値を検証するテストを増やす

このバグを長期間見逃していた理由は、既存のテストが「合成結果の画像サイズが正しいか」「nilではないか」までしか見ていなかったことです。draw(at:)は例外を投げず、サイズが正しい画像を返すため、構造的なテストではこの種の不具合を検出できません。

今回、半透明レイヤーの結合を検証するテストを新設したことで、初回の実行で失敗という形で発覚しました。合成結果を扱うテストには、代表的な座標のピクセル値を実測して期待値と比較するアサーションを増やしていく方針です。

残っている確認

ブレンドモードについては、不透明度と同じsaveGStateのスコープ内で無視されていた以上、.normal以外のモードでも同様の問題が起きていると見て、次に検証します。今回のピクセル値テストは不透明度の検証を主眼に追加したもので、.multiply.screenといった個別のブレンドモードについては、結合後のピクセル値を1つずつ確認するテストをまだ追加できていません。