📱

開発ビルドのアプリアイコンの視認性を高める

2024/12/07に公開

はじめに

この記事は Unity Advent Calendar 2024 シリーズ1 7日目の記事です。

前日の記事は @41h0 さんの Unity×Apple Vision Proで実現する擬似的空間オーディオ という記事でした。
いつか Apple Vision Pro 向けのプロダクト開発をやってみたいと思っているので、ワクワクしながら拝読いたしました。
まぁ、まずは家庭内稟議を通してデバイスを入手するところからなんですけどね… 😅

前置き

筆者が務める株式会社キッズスターでは、ごっこランド や GokkoWorld [1] というモバイルアプリを開発・運営しています。
これらのアプリの実機ビルドは自前のビルドシステムを通して作られるようになっており、ビルドされた Player は DeployGate にアップロードして検証端末にインストールしたり社員の子供にテストプレイしてもらえるようにしています。

本稿では、社内から「DeployGate にアップロードされたリビジョン一覧の中から本番ビルドを探しやすくしたい」という要望が挙がり無事対応できたので、「開発ビルドのアプリアイコンの視認性を高める」と題して「今、実機にインストールされている(しようとしている) Unity 製アプリが開発版なのか?製品版なのか?を分かりやすくしてみたよ!」というお話をしたいと思います。

方針

今回、「DeployGate にアップロードされたリビジョン一覧の中から本番ビルドを探しやすくしたい」という要望を実現するために、「アプリアイコンを加工することで開発ビルドと本番ビルドを見分けられるようにする」といった方針を立てました。
もう少し具体的に言うと、「開発ビルドのアイコンに開発版であることが分かるような画像を被せる」ことで開発ビルドと本番ビルドを見分けられると考え、以下のような仕様で実装することにしました。

  1. ビルド前の処理として実行
  2. Player Settings に設定済のアイコンに対してのみ画像処理を行う
  3. 「開発版であることが分かるような透過画像」を重ねる
  4. Player Settings のアイコン設定を加工したアイコン画像で上書きする
  5. ビルドが終わったらお掃除する

実装

1. ビルド前処理

UnityEditor.Build.IPreprocessBuildWithReport というインタフェースを実装した型を Editor 向け Assembly 内に定義しておくと、UnityEditor.BuildPipeline.BuildPlayer() メソッド実行時の前処理を実装することができます。

IPreprocessBuildWithReport は以下のようなインタフェースになっています。

IPreprocessBuildWithReport
using UnityEditor.Build.Reporting;

namespace UnityEditor.Build
{
    public interface IPreprocessBuildWithReport : IOrderedCallback
    {
        // ビルド前に呼び出される
        void OnPreprocessBuild(BuildReport report);
    }
}

IPreprocessBuildWithReport が継承している UnityEditor.Build.IOrderedCallback は以下のようなインタフェースです。

IOrderedCallback
namespace UnityEditor.Build
{
    public interface IOrderedCallback
    {
        int callbackOrder { get; }
    }
}

今回はアイコン画像を加工する処理を実装することになるので、仮に OverwriteIcons() というメソッドに実装を書いていくことにしてみます。
動作確認のために毎回ビルドすると時間が掛かってしまうので、メニューから呼び出せるようにもしておきます。

OnPreprocessBuild() と [MenuItem()] の登録
public void OnPreprocessBuild(BuildReport report)
{
    OverwriteIcons();
}

[MenuItem("Build/Overwrite Icons")]
private static void OverwriteIcons()
{
}


こんな感じになります

2. Player Settings からアイコン設定を取得


これは iOS の例

アイコン画像を加工するにあたって、「現在設定されているアイコン」の情報が欲しいので Player Settings から取得します。
UnityEditor.PlayerSettings.GetIcons() メソッドを用いて取得するのが手っ取り早いのですが、後述の事情によりプラットフォーム毎の詳細なアイコン設定に関しては UnityEditor.PlayerSettings.GetPlatformIcons() メソッドを用います。
これらのメソッドを用いる際に UnityEditor.BuildTargetGroup という列挙値や UnityEditor.Build.NamedBuildTarget という構造体が必要になることが多いので、「現在向いている BuildTarget から求められる各値」を取得できるアクセサも用意しておきます。
また、PlayerSettings.GetPlatformIcons() メソッドの第二引数には UnityEditor.PlatformIconKind というクラスのインスタンスを渡す必要があるので、UnityEditor.PlayerSettings.GetSupportedIconKinds() メソッドから貰える PlatformIconKind の配列を利用します。

private static BuildTargetGroup CurrentBuildTargetGroup => BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget);
private static NamedBuildTarget CurrentNamedBuildTarget => NamedBuildTarget.FromBuildTargetGroup(CurrentBuildTargetGroup);

private static void OverwriteDefaultIcon()
{
    // Texture2D[] が返される
    var icons = PlayerSettings.GetIcons(NamedBuildTarget.Unknown, IconKind.Any);
}

private static void OverwritePlatformIcons()
{
    var supportedIconKinds = PlayerSettings.GetSupportedIconKinds(CurrentNamedBuildTarget);
    foreach (var supportedIconKind in supportedIconKinds)
    {
        // PlatformIcon[] が返される
        var platformIcons = PlayerSettings.GetPlatformIcons(CurrentNamedBuildTarget, supportedIconKind);
    }
}

PlayerSettings.GetPlatformIcons() を使う事情

PlayerSettings.GetIcons() でも第一引数に渡した NamedBuildTarget で上書きするように設定されているアイコンは Texture2D の配列で貰えます。

しかし、後のステップでカバー画像を被せた Texture2D を実アセットとして保存する必要があり、その際のファイル名として PlatformIconKind の名称を用いたかったので、Texture2D のインスタンスに加えて「どこの設定なのか?」という情報も持っている PlatformIcon 型の配列を返してくれる PlayerSettings.GetPlatformIcons() を利用しています。

「別に、配列のインデックスをファイル名に追加すればええやん」という声が聞こえた気もしますが、その辺は筆者の拘りということで😅

3. カバー画像を被せる

本稿の核となる「既存のアイコン画像にカバー画像を被せて新しいアイコン画像を作る」という処理を実装します。
今回は以下の画像を被せることにします。


左上の領域以外は Alpha = 0.0f な透過 PNG です

実装の大枠としては以下のようになります。

  • カバー画像を AssetDatabase から読み込み
  • カバー画像を適切なサイズにリサイズ
  • 元のアイコン画像とカバー画像の画素を、透過を考慮して重ねて新しい画像の Color 配列を作る
  • 新しいファイルを保存
private static Texture2D? _coverTexture2D;
private static Texture2D CoverTexture2D => _coverTexture2D ??= LoadCoverTexture2D();

private static Texture2D LoadCoverTexture2D()
{
    // カバー画像を読み込み
    return AssetDatabase.LoadAssetAtPath<Texture2D>("Assets/Editor/Images/Icons/CoverForDevelopmentIcon.png");
}

private static Texture2D CombineCoverTexture(Texture2D sourceTexture2D)
{
    var (sourceWidth, sourceHeight) = (sourceTexture2D.width, sourceTexture2D.height);

    // カバー画像を元のアイコンサイズにリサイズ
    var coverRenderTexture = RenderTexture.GetTemporary(sourceWidth, sourceHeight, 0, RenderTextureFormat.Default, RenderTextureReadWrite.Default);
    Graphics.Blit(CoverTexture2D, coverRenderTexture);

    var originalRenderTexture = RenderTexture.active;

    // リサイズしたカバー画像の RenderTexture を Texture2D に焼く
    RenderTexture.active = coverRenderTexture;
    var coverTexture2D = new Texture2D(sourceWidth, sourceHeight, TextureFormat.RGBA32, false);
    coverTexture2D.ReadPixels(new Rect(0, 0, sourceWidth, sourceHeight), 0, 0);
    coverTexture2D.Apply();

    // RenderTexture.active を元に戻して、Temporary な RenderTexture のメモリを解放
    RenderTexture.active = originalRenderTexture;
    RenderTexture.ReleaseTemporary(coverRenderTexture);

    // 同サイズになったアイコン画像とカバー画像の画素を取得
    var sourcePixels = sourceTexture2D.GetPixels();
    var coverPixels = coverTexture2D.GetPixels();
    // 合成したアイコンの画素としての Color 配列を確保
    var combinedPixels = new Color[sourcePixels.Length];
    for (var i = 0; i < sourcePixels.Length; i++)
    {
        var sourcePixel = sourcePixels[i];
        var coverPixel = coverPixels[i];

        // アルファを考慮しつつアイコン画像の画素にカバー画像の画素を重ねる
        var sourceAlpha = sourcePixel.a - coverPixel.a;
        var coverAlpha = coverPixel.a;
        var (r, g, b, a) = (
            sourcePixel.r * sourceAlpha + coverPixel.r * coverAlpha,
            sourcePixel.g * sourceAlpha + coverPixel.g * coverAlpha,
            sourcePixel.b * sourceAlpha + coverPixel.b * coverAlpha,
            Mathf.Min(1.0f, sourceAlpha + coverAlpha)
        );
        if (Mathf.Approximately(0.0f, sourceAlpha) && Mathf.Approximately(0.0f, coverAlpha))
        {
            a = 0.0f;
        }

        combinedPixels[i] = new Color(r, g, b, a);
    }

    // 一時的に作った Texture2D のインスタンスは破棄する
    Object.DestroyImmediate(coverTexture2D);

    // 合成した Texture2D インスタンスを作成し、Color 配列を画素として設定
    var combinedTexture2D = new Texture2D(sourceWidth, sourceHeight, TextureFormat.RGBA32, false);
    combinedTexture2D.SetPixels(combinedPixels);
    combinedTexture2D.Apply();

    return combinedTexture2D;
}

保存処理も実装します。

// Default Icon の時は PlatformIconKind が null になる
private static Texture2D[] CombineAndSaveIcons(Texture2D[] iconTexture2Ds, NamedBuildTarget namedBuildTarget, PlatformIconKind? platformIconKind = default)
{
    var importedCombinedIcons = new Texture2D[iconTexture2Ds.Length];
    var hasManyIcons = iconTexture2Ds.Length > 1;
    foreach (var (iconTexture2D, index) in iconTexture2Ds.Select((texture2D, index) => (texture2D, index)))
    {
        if (iconTexture2D == default)
        {
            continue;
        }
        var combinedIconTexture2D = CombineCoverTexture(iconTexture2D);
        var iconName = $"Icon{(!string.IsNullOrEmpty(namedBuildTarget.TargetName) ? $".{namedBuildTarget.TargetName}" : string.Empty)}{(platformIconKind != default ? $".{platformIconKind}" : string.Empty)}{(hasManyIcons ? $".{index}" : string.Empty)}";
        var assetPath = $"Assets/Editor/Images/Icons/Combined.{iconName}.png";
        combinedIconTexture2D.name = iconName;
        File.WriteAllBytes(assetPath, combinedIconTexture2D.EncodeToPNG());
        AssetDatabase.ImportAsset(assetPath);
        // TextureImporter のインスタンスを取得し、アイコン用の設定を施す
        if (AssetImporter.GetAtPath(assetPath) is not TextureImporter textureImporter)
        {
            throw new InvalidOperationException($"Failed to get TextureImporter for '{assetPath}'");
        }
        textureImporter.textureType = TextureImporterType.GUI;
        textureImporter.alphaIsTransparency = true;
        // アイコンは非圧縮であるべき
        textureImporter.textureCompression = TextureImporterCompression.Uncompressed;
        textureImporter.SaveAndReimport();
        importedCombinedIcons[index] = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
    }
    return importedCombinedIcons;
}

4. Player Settings にアイコン設定を設定

カバー画像を重ねたアイコン画像が作れたら、作った画像を Player ビルドのアイコンとして利用するための設定を行います。

アイコン設定を取得した際に利用したメソッドに対応する SetIcons メソッドや SetPlatformIcons メソッドが用意されているので、引数として要求される情報を再構築して実行します。

// 後で設定を元に戻す時のために静的プロパティに保持する
private static Texture2D[]? OriginalDefaultIconTextures { get; set; }
private static Texture2D[]? CombinedDefaultIconTextures { get; set; }
private static Dictionary<PlatformIconKind, Texture2D[]>? OriginalPlatformIconTexturesMap { get; set; }
private static Dictionary<PlatformIconKind, Texture2D[]>? CombinedPlatformIconTexturesMap { get; set; }

private static void OverwriteDefaultIcon()
{
    var icons = PlayerSettings.GetIcons(NamedBuildTarget.Unknown, IconKind.Any);
    OriginalDefaultIconTextures = icons;
    if (icons.Length == 0)
    {
        return;
    }

    CombinedDefaultIconTextures = CombineAndSaveIcons(icons, NamedBuildTarget.Unknown);
    PlayerSettings.SetIcons(NamedBuildTarget.Unknown, CombinedDefaultIconTextures, IconKind.Any);
}

private static void OverwritePlatformIcons()
{
    var supportedIconKinds = PlayerSettings.GetSupportedIconKinds(CurrentNamedBuildTarget);
    var platformIconsMap = supportedIconKinds.ToDictionary(x => x, x => PlayerSettings.GetPlatformIcons(CurrentNamedBuildTarget, x));
    OriginalPlatformIconTexturesMap = new Dictionary<PlatformIconKind, Texture2D[]>();
    CombinedPlatformIconTexturesMap = new Dictionary<PlatformIconKind, Texture2D[]>();
    foreach (var (platformIconKind, platformIcons) in platformIconsMap)
    {
        var combinedPlatformIcons = new PlatformIcon[platformIcons.Length];
        foreach (var (platformIcon, index) in platformIcons.Select((platformIcon, index) => (platformIcon, index)))
        {
            OriginalPlatformIconTexturesMap[platformIconKind] = platformIcon.GetTextures();
            var combinedTextures = CombineAndSaveIcons(platformIcon.GetTextures(), CurrentNamedBuildTarget, platformIconKind);
            platformIcon.SetTextures(combinedTextures);
            combinedPlatformIcons[index] = platformIcon;
            CombinedPlatformIconTexturesMap[platformIconKind] = combinedTextures;
        }
        PlayerSettings.SetPlatformIcons(CurrentNamedBuildTarget, platformIconKind, combinedPlatformIcons);
    }
}

5. お掃除

ビルドサーバ的なところで実行している場合は、ビルド毎でプロジェクトが破棄されることも多く、新たに作ったアイコン画像アセットが不要な差分として残ることもなかったりするので、無理にお掃除しなくても良いのですが、年末ですし(?)掃除はしておきましょう。
お掃除はビルド後に実行したいので、UnityEditor.Build.IPostprocessBuildWithReport というインタフェースを実装します。
IPostprocessBuildWithReport は以下のようなインタフェースになっています。

IPostprocessBuildWithReport
using UnityEditor.Build.Reporting;

namespace UnityEditor.Build
{
    public interface IPostprocessBuildWithReport : IOrderedCallback
    {
        // ビルド後に呼び出される
        void OnPostprocessBuild(BuildReport report);
    }
}

こちらもビルド前処理と同様にメニューからも呼び出せるようにしておきましょう。

OnPostprocessBuild() と [MenuItem()] の登録
public void OnPostprocessBuild(BuildReport report)
{
    RevertIcons();
}

[MenuItem("Build/Revert Icons")]
private static void RevertIcons()
{
}


こんな感じになります

System.IO.File.Delete() を使ってしまうと .meta ファイルが残ってしまうので、UnityEditor.AssetDatabase.DeleteAsset() を使いましょう。

private static void RevertDefaultIcon()
{
    if (OriginalDefaultIconTextures == default || CombinedDefaultIconTextures == default)
    {
        return;
    }
    foreach (var iconTexture2D in CombinedDefaultIconTextures)
    {
        if (iconTexture2D == default)
        {
            continue;
        }
        var assetPath = AssetDatabase.GetAssetPath(iconTexture2D);
        AssetDatabase.DeleteAsset(assetPath);
    }
    PlayerSettings.SetIcons(NamedBuildTarget.Unknown, OriginalDefaultIconTextures, IconKind.Any);
}

private static void RevertPlatformIcons()
{
    if (OriginalPlatformIconTexturesMap == default || CombinedPlatformIconTexturesMap == default)
    {
        return;
    }
    foreach (var (platformIconKind, iconTexture2Ds) in CombinedPlatformIconTexturesMap)
    {
        foreach (var iconTexture2D in iconTexture2Ds)
        {
            if (iconTexture2D == default)
            {
                continue;
            }
            var assetPath = AssetDatabase.GetAssetPath(iconTexture2D);
            AssetDatabase.DeleteAsset(assetPath);
        }

        if (!OriginalPlatformIconTexturesMap.ContainsKey(platformIconKind))
        {
            continue;
        }

        var originalPlatformIcons = PlayerSettings.GetPlatformIcons(CurrentNamedBuildTarget, platformIconKind);
        foreach (var (platformIcon, index) in originalPlatformIcons.Select((platformIcon, index) => (platformIcon, index)))
        {
            platformIcon.SetTextures(OriginalPlatformIconTexturesMap[platformIconKind]);
            originalPlatformIcons[index] = platformIcon;
        }
        PlayerSettings.SetPlatformIcons(CurrentNamedBuildTarget, platformIconKind, originalPlatformIcons);
    }
}

完成品

製品版 開発版
OverwriteIconsProcessor.cs 全体
OverwriteIconsProcessor.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
using Object = UnityEngine.Object;

namespace Monry.ProcessIconOnBuild.Editor
{
    public class OverwriteIconsProcessor : IPreprocessBuildWithReport, IPostprocessBuildWithReport
    {
        public int callbackOrder => 0;

        private static Texture2D? _coverTexture2D;
        private static Texture2D CoverTexture2D => _coverTexture2D ??= LoadCoverTexture2D();

        private static BuildTargetGroup CurrentBuildTargetGroup => BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget);
        private static NamedBuildTarget CurrentNamedBuildTarget => NamedBuildTarget.FromBuildTargetGroup(CurrentBuildTargetGroup);

        private static Texture2D[]? OriginalDefaultIconTextures { get; set; }
        private static Texture2D[]? CombinedDefaultIconTextures { get; set; }
        private static Dictionary<PlatformIconKind, Texture2D[]>? OriginalPlatformIconTexturesMap { get; set; }
        private static Dictionary<PlatformIconKind, Texture2D[]>? CombinedPlatformIconTexturesMap { get; set; }

        public void OnPreprocessBuild(BuildReport report)
        {
            OverwriteIcons();
        }

        public void OnPostprocessBuild(BuildReport report)
        {
            RevertIcons();
        }

        [MenuItem("Build/Overwrite Icons")]
        private static void OverwriteIcons()
        {
            OverwriteDefaultIcon();
            OverwritePlatformIcons();
        }

        [MenuItem("Build/Revert Icons")]
        private static void RevertIcons()
        {
            RevertDefaultIcon();
            RevertPlatformIcons();
        }

        private static void OverwriteDefaultIcon()
        {
            var icons = PlayerSettings.GetIcons(NamedBuildTarget.Unknown, IconKind.Any);
            OriginalDefaultIconTextures = icons;
            if (icons.Length == 0)
            {
                return;
            }

            CombinedDefaultIconTextures = CombineAndSaveIcons(icons, NamedBuildTarget.Unknown);
            PlayerSettings.SetIcons(NamedBuildTarget.Unknown, CombinedDefaultIconTextures, IconKind.Any);
        }

        private static void OverwritePlatformIcons()
        {
            var supportedIconKinds = PlayerSettings.GetSupportedIconKinds(CurrentNamedBuildTarget);
            var platformIconsMap = supportedIconKinds.ToDictionary(x => x, x => PlayerSettings.GetPlatformIcons(CurrentNamedBuildTarget, x));
            OriginalPlatformIconTexturesMap = new Dictionary<PlatformIconKind, Texture2D[]>();
            CombinedPlatformIconTexturesMap = new Dictionary<PlatformIconKind, Texture2D[]>();
            foreach (var (platformIconKind, platformIcons) in platformIconsMap)
            {
                var combinedPlatformIcons = new PlatformIcon[platformIcons.Length];
                foreach (var (platformIcon, index) in platformIcons.Select((platformIcon, index) => (platformIcon, index)))
                {
                    OriginalPlatformIconTexturesMap[platformIconKind] = platformIcon.GetTextures();
                    var combinedTextures = CombineAndSaveIcons(platformIcon.GetTextures(), CurrentNamedBuildTarget, platformIconKind);
                    platformIcon.SetTextures(combinedTextures);
                    combinedPlatformIcons[index] = platformIcon;
                    CombinedPlatformIconTexturesMap[platformIconKind] = combinedTextures;
                }
                PlayerSettings.SetPlatformIcons(CurrentNamedBuildTarget, platformIconKind, combinedPlatformIcons);
            }
        }

        private static void RevertDefaultIcon()
        {
            if (OriginalDefaultIconTextures == default || CombinedDefaultIconTextures == default)
            {
                return;
            }
            foreach (var iconTexture2D in CombinedDefaultIconTextures)
            {
                if (iconTexture2D == default)
                {
                    continue;
                }
                var assetPath = AssetDatabase.GetAssetPath(iconTexture2D);
                AssetDatabase.DeleteAsset(assetPath);
            }
            PlayerSettings.SetIcons(NamedBuildTarget.Unknown, OriginalDefaultIconTextures, IconKind.Any);
        }

        private static void RevertPlatformIcons()
        {
            if (OriginalPlatformIconTexturesMap == default || CombinedPlatformIconTexturesMap == default)
            {
                return;
            }
            foreach (var (platformIconKind, iconTexture2Ds) in CombinedPlatformIconTexturesMap)
            {
                foreach (var iconTexture2D in iconTexture2Ds)
                {
                    if (iconTexture2D == default)
                    {
                        continue;
                    }
                    var assetPath = AssetDatabase.GetAssetPath(iconTexture2D);
                    AssetDatabase.DeleteAsset(assetPath);
                }

                if (!OriginalPlatformIconTexturesMap.ContainsKey(platformIconKind))
                {
                    continue;
                }

                var originalPlatformIcons = PlayerSettings.GetPlatformIcons(CurrentNamedBuildTarget, platformIconKind);
                foreach (var (platformIcon, index) in originalPlatformIcons.Select((platformIcon, index) => (platformIcon, index)))
                {
                    platformIcon.SetTextures(OriginalPlatformIconTexturesMap[platformIconKind]);
                    originalPlatformIcons[index] = platformIcon;
                }
                PlayerSettings.SetPlatformIcons(CurrentNamedBuildTarget, platformIconKind, originalPlatformIcons);
            }
        }

        private static Texture2D LoadCoverTexture2D()
        {
            return AssetDatabase.LoadAssetAtPath<Texture2D>("Assets/Editor/Images/Icons/CoverForDevelopment.png");
        }

        private static Texture2D CombineCoverTexture(Texture2D sourceTexture2D)
        {
            var (sourceWidth, sourceHeight) = (sourceTexture2D.width, sourceTexture2D.height);

            var coverRenderTexture = RenderTexture.GetTemporary(sourceWidth, sourceHeight, 0, RenderTextureFormat.Default, RenderTextureReadWrite.Default);
            Graphics.Blit(CoverTexture2D, coverRenderTexture);

            var originalRenderTexture = RenderTexture.active;

            RenderTexture.active = coverRenderTexture;
            var coverTexture2D = new Texture2D(sourceWidth, sourceHeight, TextureFormat.RGBA32, false);
            coverTexture2D.ReadPixels(new Rect(0, 0, sourceWidth, sourceHeight), 0, 0);
            coverTexture2D.Apply();

            RenderTexture.active = originalRenderTexture;
            RenderTexture.ReleaseTemporary(coverRenderTexture);

            var sourcePixels = sourceTexture2D.GetPixels();
            var coverPixels = coverTexture2D.GetPixels();
            var combinedPixels = new Color[sourcePixels.Length];
            for (var i = 0; i < sourcePixels.Length; i++)
            {
                var sourcePixel = sourcePixels[i];
                var coverPixel = coverPixels[i];

                var sourceAlpha = sourcePixel.a - coverPixel.a;
                var coverAlpha = coverPixel.a;

                var (r, g, b, a) = (
                    sourcePixel.r * sourceAlpha + coverPixel.r * coverAlpha,
                    sourcePixel.g * sourceAlpha + coverPixel.g * coverAlpha,
                    sourcePixel.b * sourceAlpha + coverPixel.b * coverAlpha,
                    Mathf.Min(1.0f, sourceAlpha + coverAlpha)
                );
                if (Mathf.Approximately(0.0f, sourceAlpha) && Mathf.Approximately(0.0f, coverAlpha))
                {
                    a = 0.0f;
                }

                combinedPixels[i] = new Color(r, g, b, a);
            }

            Object.DestroyImmediate(coverTexture2D);

            var combinedTexture2D = new Texture2D(sourceWidth, sourceHeight, TextureFormat.RGBA32, false);
            combinedTexture2D.SetPixels(combinedPixels);
            combinedTexture2D.Apply();

            return combinedTexture2D;
        }

        private static Texture2D[] CombineAndSaveIcons(Texture2D[] iconTexture2Ds, NamedBuildTarget namedBuildTarget, PlatformIconKind? platformIconKind = default)
        {
            var importedCombinedIcons = new Texture2D[iconTexture2Ds.Length];
            var hasManyIcons = iconTexture2Ds.Length > 1;
            foreach (var (iconTexture2D, index) in iconTexture2Ds.Select((texture2D, index) => (texture2D, index)))
            {
                if (iconTexture2D == default)
                {
                    continue;
                }
                var combinedIconTexture2D = CombineCoverTexture(iconTexture2D);
                var iconName = $"Icon{(!string.IsNullOrEmpty(namedBuildTarget.TargetName) ? $".{namedBuildTarget.TargetName}" : string.Empty)}{(platformIconKind != default ? $".{platformIconKind}" : string.Empty)}{(hasManyIcons ? $".{index}" : string.Empty)}";
                var assetPath = $"Assets/Editor/Images/Icons/Combined.{iconName}.png";
                combinedIconTexture2D.name = iconName;
                File.WriteAllBytes(assetPath, combinedIconTexture2D.EncodeToPNG());
                AssetDatabase.ImportAsset(assetPath);
                if (AssetImporter.GetAtPath(assetPath) is not TextureImporter textureImporter)
                {
                    throw new InvalidOperationException($"Failed to get TextureImporter for '{assetPath}'");
                }
                textureImporter.textureType = TextureImporterType.GUI;
                textureImporter.alphaIsTransparency = true;
                textureImporter.textureCompression = TextureImporterCompression.Uncompressed;
                textureImporter.SaveAndReimport();
                importedCombinedIcons[index] = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
            }
            return importedCombinedIcons;
        }
    }
}

おわりに

ということで、開発ビルドのアプリアイコンの視認性を高めてみました。

今回の記事で用いたコードやアイコン画像はこちらのリポジトリにコミットしてありますので、トライしたい方は参考にしてみてください。

Unity Advent Calendar 2024 シリーズ1 8日目は @tyanmahou さんの「TextMeshProで複数フォントを1つのアトラスに無理やり書き込む方法」です!

脚注
  1. ごっこランドの海外版で、2024年12月現在ベトナム向けに展開中 ↩︎

Discussion