uGUI で透明じゃないところだけに反応するボタンを作った (Unity)

More than 1 year has passed since last update.

こんにちは。AdventCalendarというものに初めて参加します。
12月4日分の記事です。よろしくお願いします。
http://miyatin.me/
こちらでも公開してます。

Unityのボタンはデフォルトでは透明の部分でもタップできちゃうので,それを解決したボタンを紹介したいと思います。

デフォルトでなんとか頑張れないか

クリックしたところの透明度をとる。これくらいできるでしょ?と思ってやってみると,これは簡単にできた。

image = GetComponent<Image>();
var alpha = image.sprite.texture.GetPixel(0, 0).a;

「なんだ,めっちゃ簡単やん」と思ったんですが,いざiOSビルドしてみるとエディタではうまく動作していたのに,実機ではうまくいかない・・。
他にも悩んでる方はいらっしゃるようで。
http://forum.unity3d.com/threads/help-why-does-getpixels-not-work-for-ios.153902/

これの原因はTextureの設定です。普通のテクスチャであればRead/Write Enabledプロパティがあり,それにチェックを入れるとiOSでも読み込めるようになりますが,uGUIではできません。どうしたものか・・。

独自でなんとかするしかない

もしかしたら,デフォルトでもっと頑張ればなんとかなるかもしれません。しかし,僕のUnity力ではどうにもならなかったので,今から紹介する方法で解決しました。

そもそも欲しいのは透明度だけ

デフォルトで読めるようにしてもこちらで紹介されているように,メモリ消費量は倍になります。Unityでゲーム開発する際はモバイル向けがほとんどだと思いますので,メモリ消費量が倍になるのは少し胃が痛くなりますね。

独自のalpha値マップの仕組みをつくる

僕はとりあえず,どこかしらのタイミングで画像の情報からアルファ値をファイルに保存しておこうと思いました。UnityのResources.Loadではバイト値を読み込むことができます。

var bytes = Resources.Load<TextAsset>("binary").bytes

これを利用します。以前,ファイル読み込み等の処理を書いている時にPlain Textとバイナリでは読み込み速度処理速度が全然違うという知見はあったのでバイナリにする価値はありと思ったので。

画像からalpha値を読み込んでファイルに保存しておくの巻

さて,保存するタイミングですが以下のようなタイミングが考えられます。
1. エディタメニューにファイル生成メニューを作成し,それを使うことで生成する
2. スプライトパッキングを改造する
3. ファイル追加・変更時に処理する

僕が作っているゲームではUnityのスプライトパッキングを使いまくっているので,そこにフックさせるのが一番いいかなと思ったので,それを採用しました。
でも,よくよく考えるとファイルが追加・変更された時になにか処理が書けるのであればそこでもいいですね。
http://tsubakit1.hateblo.jp/entry/20140615/1402844126
こちらを参照する限りできそうです。

AlphaMapCreater.cs
#if UNITY_EDITOR

using System.IO;
using UnityEngine;
using UnityEditor;

public static class AlphaMapCreater
{
    public static void Create(Texture2D texture)
    {
        var map = texture.GetRawTextureData();
        using (Stream stream = File.OpenWrite("Assets/Resources/AlphaMap/" + texture.name + ".bytes")) {
            using (var writer = new BinaryWriter(stream)) {
                writer.Write((uint)texture.width);
                writer.Write((uint)texture.height);
                for (var i = 0; i < map.Length; i += 4 * 8)
                {
                    int j = i + 4, k = i + 8, l = i + 12, m = i + 16, n = i + 20, o = i + 24, p = i + 28;
                    byte b = (byte)(
                        128 * Bit(map, p) +
                        64  * Bit(map, o) +
                        32  * Bit(map, n) +
                        16  * Bit(map, m) +
                        8   * Bit(map, l) +
                        4   * Bit(map, k) +
                        2   * Bit(map, j) +
                        1   * Bit(map, i));
                    writer.Write(b);
                }
            }
        }
    }

    private static int Bit(byte[] map, int x)
    {
        if (map.Length <= x)
            return 0;
        return map[x] > 1? 1 : 0;
    }

}

#endif

僕の下手くそなコードでなにやってるかわかった人がいたらすごいですね・・。

アルファ値さえいらない

僕がやりたいのはアルファ値をとることではありません。ボタンが押せるかどうかを保存したいのです。(そう考えるとAlphaMapという命名はおかしいですが)

つまり,押せる = 1, 押せない = 0の情報だけでいいのです。そして,ファイルサイズやメモリに展開するサイズは極力抑えたいところです。

0か1ということは2進数だな?(当然)

この辺から専門的な知識が間違っている可能性があります。すいません。
2進数でいいのはわかるのですが,残念ながらメモリの最小単位は1byte = 8bit = 2進数が8個ですね。
例えば

00110100

これですね。つまり,画像の8ピクセル分の押せるか押せないかの情報を保存することができます。おそらく,可能な限り最小な方法です。

var map = texture.GetRawTextureData();

これによって,テクスチャのピクセルの情報を1次元配列を取得できます。内容は,左上のピクセルから右下の最後のピクセルまでのRGBAが1つずつ格納されている配列です。

map = new []{r0, g0, b0, a0, r1, g1, b1, a1, ... , rn, gn, bn, an};

欲しい情報は4の倍数の場所にありますね。そして,それを8個区切りで1byteにしていきます。

for (var i = 0; i < map.Length; i += 4 * 8)
{
    int j = i + 4, k = i + 8, l = i + 12, m = i + 16, n = i + 20, o = i + 24, p = i + 28;
    byte b = (byte)(
        128 * Bit(map, p) +
        64  * Bit(map, o) +
        32  * Bit(map, n) +
        16  * Bit(map, m) +
        8   * Bit(map, l) +
        4   * Bit(map, k) +
        2   * Bit(map, j) +
        1   * Bit(map, i));
    writer.Write(b);
}

private static int Bit(byte[] map, int x)
{
    if (map.Length <= x)
        return 0;
    return map[x] > 1? 1 : 0;
}

これがその処理です。Bit関数はちょっとこの処理を見やすくするためだけに作ったものです。
以上により,Assets/Resources/AlphaMap以下にファイルを保存する準備ができました。

スプライトパッカーを改造してパッキング時にファイルを作らせる

Unityではスプライトのパッキングポリシーを自分でカスタマイズできます。私はこれを応用してみようと思いました。

http://docs.unity3d.com/ja/current/Manual/SpritePacker.html

Unityのサイトでデフォルトパッキングポリシーのソースコードが公開されています。私はそれを元に作りました。

ReadablePackingPolicy.cs
#if UNITY_EDITOR

using System;
using System.Linq;
using UnityEngine;
using UnityEditor;
using UnityEditor.Sprites;
using System.Collections.Generic;

class ReadablePackingPolicy : UnityEditor.Sprites.IPackerPolicy
{
    protected class Entry
    {
        public Sprite            sprite;
        public AtlasSettings     settings;
        public string            atlasName;
        public SpritePackingMode packingMode;
        public int               anisoLevel;
    }

    private const uint kDefaultPaddingPower = 2; // Good for base and two mip levels.

    public virtual int GetVersion() { return 1; }

    protected virtual string TagPrefix { get { return "[TIGHT]"; } }
    protected virtual bool AllowTightWhenTagged { get { return true; } }

    public void OnGroupAtlases(BuildTarget target, PackerJob job, int[] textureImporterInstanceIDs)
    {
        var entries = new List<Entry>();

        foreach (int instanceID in textureImporterInstanceIDs)
        {
            var ti = EditorUtility.InstanceIDToObject(instanceID) as TextureImporter;
            TextureFormat textureFormat;
            ColorSpace colorSpace;
            int compressionQuality;
            if (ti == null)
                continue;
            ti.ReadTextureImportInstructions(target, out textureFormat, out colorSpace, out compressionQuality);

            var tis = new TextureImporterSettings();
            ti.ReadTextureSettings(tis);
            ti.isReadable = true;
            tis.readable = true;

            Sprite[] sprites = AssetDatabase.LoadAllAssetRepresentationsAtPath(ti.assetPath).Select(x => x as Sprite).Where(x => x != null).ToArray();

            foreach (Sprite sprite in sprites)
            {
                var entry = new Entry();
                entry.sprite = sprite;
                entry.settings.format = textureFormat;
                entry.settings.colorSpace = colorSpace;
                entry.settings.compressionQuality = compressionQuality;
                entry.settings.filterMode = Enum.IsDefined(typeof(FilterMode), ti.filterMode) ? ti.filterMode : FilterMode.Bilinear;
                entry.settings.maxWidth = 2048;
                entry.settings.maxHeight = 2048;
                entry.settings.generateMipMaps = ti.mipmapEnabled;
                if (ti.mipmapEnabled)
                    entry.settings.paddingPower = kDefaultPaddingPower;
                entry.atlasName = ParseAtlasName(ti.spritePackingTag);
                entry.packingMode = GetPackingMode(ti.spritePackingTag, tis.spriteMeshType);
                entry.anisoLevel = ti.anisoLevel;

                entries.Add(entry);
            }

            Resources.UnloadAsset(ti);

            foreach (var sprite in sprites)
            {
                var m_tex = sprite.texture;
                AlphaMapCreater.Create(m_tex);
            }
        }

        // First split sprites into groups based on atlas name
        var atlasGroups =
            from e in entries
            group e by e.atlasName;
        foreach (var atlasGroup in atlasGroups)
        {
            int page = 0;
            // Then split those groups into smaller groups based on texture settings
            var settingsGroups =
                from t in atlasGroup
                group t by t.settings;
            foreach (var settingsGroup in settingsGroups)
            {
                string atlasName = atlasGroup.Key;
                if (settingsGroups.Count() > 1)
                    atlasName += string.Format(" (Group {0})", page);

                AtlasSettings settings = settingsGroup.Key;
                settings.anisoLevel = 1;

                // Use the highest aniso level from all entries in this atlas
                if (settings.generateMipMaps)
                    foreach (Entry entry in settingsGroup)
                        if (entry.anisoLevel > settings.anisoLevel)
                            settings.anisoLevel = entry.anisoLevel;

                job.AddAtlas(atlasName, settings);

                foreach (Entry entry in settingsGroup)
                {
                    job.AssignToAtlas(atlasName, entry.sprite, entry.packingMode, SpritePackingRotation.None);
                }

                ++page;
            }

        }
    }

    protected bool IsTagPrefixed(string packingTag)
    {
        packingTag = packingTag.Trim();
        if (packingTag.Length < TagPrefix.Length)
            return false;
        return (packingTag.Substring(0, TagPrefix.Length) == TagPrefix);
    }

    private string ParseAtlasName(string packingTag)
    {
        string name = packingTag.Trim();
        if (IsTagPrefixed(name))
            name = name.Substring(TagPrefix.Length).Trim();
        return (name.Length == 0) ? "(unnamed)" : name;
    }

    private SpritePackingMode GetPackingMode(string packingTag, SpriteMeshType meshType)
    {
        if (meshType == SpriteMeshType.Tight)
            if (IsTagPrefixed(packingTag) == AllowTightWhenTagged)
                return SpritePackingMode.Tight;
        return SpritePackingMode.Rectangle;
    }
}

#endif

長いコードペタペタ貼り付けてすいません・・・。
注目箇所はこちらです。


foreach (var sprite in sprites)
{
    var m_tex = sprite.texture;
    AlphaMapCreater.Create(m_tex);
}

先ほど作ったAlphaMapCreater.Createにテクスチャを渡すだけです。

Unityの設定を色々

言わずもがなですが,まず画像の設定から。

スクリーンショット 2015-12-03 0.08.45.png

Packing Tagを適当に設定しないと,パッキングされない気がするので注意です。
またResourcesフォルダに入っている画像はパッキングされないのでこれも注意です!!

スクリーンショット 2015-12-03 0.05.10.png
メニューのWindow > Sprite PackerでSprite Packerウィンドウを開いて右らへんの設定を変更するだけです。
そして,PackとかRepackとかUnity再起動とか再生とかしてると次のファイルが作成されます。
(Packで作成されるはずなのですが,なんでか色々しないと作成されませんでした・・。いつから・・。)

Assets/Resources/AlphaMap/button.bytes
...
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 00fc ffff
ffff ffff ffff ff00 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 00ff ffff
ffff ffff ffff ff03 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 80ff ffff
...

なにやらそれっぽいファイルが生成されます。

押せる場所かどうかの判定をする

いよいよ判定までやってきました。

AlphaMap.cs
using System.IO;
using UnityEngine;
using System;
using System.Collections.Generic;

public class AlphaMap
{
    private readonly byte[] bytes;
    private readonly Sprite sprite;
    private readonly uint width;
    private readonly uint height;
    private static Dictionary<string, AlphaMap> cache;
    private AlphaMap(Sprite sprite)
    {
        this.sprite = sprite;
        var alphaBytes = Resources.Load<TextAsset>("AlphaMap/" + sprite.name).bytes;
        width  = BitConverter.ToUInt32(alphaBytes, 0);
        height = BitConverter.ToUInt32(alphaBytes, 4);
        bytes = new byte[alphaBytes.Length - 8];
        Array.Copy(alphaBytes, 8, bytes, 0, bytes.Length);
    }

    static public AlphaMap Load(Sprite sprite)
    {
        cache = cache ?? new Dictionary<string, AlphaMap>();

        if (cache.ContainsKey(sprite.name))
            return cache[sprite.name];

        var map = new AlphaMap(sprite);
        cache.Add(sprite.name, map);
        return map;
    }

    public bool IsFlag(int x, int y)
    {
        var index = (int)(x + y * width) / 8;
        var flag  = 1 << (int)((x + y * width) % 8);
        try
        {
            return bytes[index] > (bytes[index] ^ flag);
        }
        catch
        {
            return false;
        }
    }
}

こちらは実装の通りです。IsFlagメソッドが実際に判定を行うメソッドです。
ビット演算になるので解説を。

さきほどの解説のように,8ピクセルのデータが1byteには入っています。なので,与えられた2次元座標を座標をあらかじめ取得してあるwidthを作って1次元にします。

var index = (int)(x + y * width) / 8;

さらに,8個の要素の中の何個目の要素かを得るために

(x + y * width) % 8

この計算をします。
これを使って,対象の桁だけ1になっている2進数を作ります。

var flag  = 1 << (int)((x + y * width) % 8);

例えば,8個中の6個目だった場合は00100000という2進数になります。

そして,実際にそのピクセルが透明か不透明かを判定します。

return bytes[index] > (bytes[index] ^ flag);

これは久しぶりに読んで,一瞬わからなかったですね,ハイ。
つまり,これは対象のビットが1だったら全体の値が元の値より小さくなるということを利用しています。
例えば
11011000に対して00010000をXORすると11001000となって,元の値より小さくなります。
11011000に対して00100000をXORすると11111000となって,元の値より大きくなります。
この性質を使った判定をしています。

あとはこれらを利用したコンポーネントをつくるだけ

ImageButton.cs
using UnityEngine;
using UnityEngine.UI;

[RequireComponent(typeof(Image))]
public class ImageButton : Button, ICanvasRaycastFilter
{
    private Canvas canvas;
    private int? canvasWidth;
    private int? canvasHeight;

    private Image image;
    private AlphaMap alphaMap;

    void Start()
    {
        image = GetComponent<Image>();
        CanvasScaler scaler;
        Transform p = transform;
        do {
            p = p.parent;
            scaler = p.GetComponent<CanvasScaler>();
        } while(scaler == null);

        canvas = scaler.GetComponent<Canvas>();
        canvasWidth = (int)scaler.referenceResolution.x;
        canvasHeight = (int)scaler.referenceResolution.y;

        alphaMap = AlphaMap.Load(image.sprite);
    }

    public bool IsRaycastLocationValid(Vector2 sp, Camera c)
    {
        var point = new Vector2 (
                        sp.x / Screen.width * canvasWidth.Value,
                        sp.y / Screen.height * canvasHeight.Value
                    );

        var screenPoint = new Vector2 (
                              transform.position.x / canvas.transform.localScale.x,
                              transform.position.y / canvas.transform.localScale.y
                          );

        var areaPosition =
            point - new Vector2 (
                screenPoint.x - image.rectTransform.pivot.x * image.rectTransform.sizeDelta.x,
                screenPoint.y - image.rectTransform.pivot.y * image.rectTransform.sizeDelta.y
            );

        return alphaMap.IsFlag((int)areaPosition.x, (int)areaPosition.y);
    }
}

ICanvasRaycastFilterインターフェスを実装していると,クリック判定が入ったかどうかを制御することができます。
詳しくは以下のサイトがオススメです。
http://tsubakit1.hateblo.jp/entry/2015/01/30/213000
(テラシュールブログにお世話になりまくってます・・。すいません。)

現在はCanvasScalerがScale With Screen Sizeの設定になってないとうまく作用しないと思います。
見てわかるように,クリックされたポジションを頑張って画像の中でのポジションに変更しています。
そして,AlphaMapのメソッドを呼び出しています。

試してみる

GameObjectをCanvas以下に作ってさきほど作ったImageButtonコンポーネントを付与して再生してみます。

image_button.gif

わけわからん画像使ってますが,どうやらうまく動いてますね。

終わりのことば

Unityはとてもゲームが簡単に作れますが,こういうときにしんどい思いをします。しかし,C#楽しいので特に問題ないですね・・。

これらのソースコードはGithubにアップしておきますので,わからない点があれば是非参考にしてください。
とても長くなりましたが,閲覧してくださり誠にありがとうございます。

https://github.com/tagia0212/ImageButton

次はhecomiさんです!よろしくお願いします。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.