Возникла задача собрать карту мира. Причем именно собрать из множества стран, стран-регионов, потому как страны должны быть кликабельны. Да проще некуда, скажете вы, всего-то и надо запилить целую карту да развесить по странам полигон-коллайдеры, пффф… Но нет, подразумевается, что страна должна будет изменять цвет на красный или черный и при клике будет выделяться белым. Кроме того, со временем на стране должны появляться красные поинты (да-да… я знаю, о чем вы подумали). Этих поинтов должно быть достаточно много на карте.

Было принято решение собрать карту при помощью Canvas. Удобная штука, экономит массу времени. Но не в этот раз.

1-я проблема — страна разных размеров и возникает ситуация, когда одна страна прозрачным участком закрывает океан, или другие страны, и клик приходится не туда, куда нужно, точнее, не туда, куда логически хотелось бы (немного обелил спрайты, что бы вы увидели весь ужас происходящего).

image

Первая мысль: проблем-то навесить Button на Image-объект и дело в шляпе, но нет, проблему это не решит, Button основывается на Image и прозрачные участки он не пропускает, то есть прозрачные участки все равно будут кнопками.
Вторая мысль: получить пиксель рисунка в точке нажатия и если пиксель не прозрачный, то мы кликнули туда, куда надо, если прозрачный, то посмотреть, какие еще есть пиксели в точке нажатии.

И вот тут ступор. Как получать пиксель рисунка, это не проблема, тут масса примеров, а вот как получить канвасовские объекты в точке нажатия? Канвас не имеет коллайдера, поэтому пускать Raycast бесполезно, ничего не вернет. А пихать на каждую image-страну полигон-коллайдер дикость.

Что ж, после прочтения справки и просмотров мануал-видео с англоговорящими индусами на Youtube пришел к выводу, что настало время использовать возможности EventSystem.

Создал скрипт для стран CountryMap и наследовал его от интерфейса IPointerClickHandler, который входит в вышеуказанный нэймспэйс. Единственный метод этого интерфейса OnPointerClick принимает на вход переменную типа PointerEventData. Из этой переменной можно получить много интересной информации, но мне нужна только позиция нажатия.

Окей, страна кликабельна (благодаря интерфейсу), позицию тапа мы знаем, осталось достать пиксель картинки под этой позицией. Пишем небольшой метод:

private bool IsAlphaPoint(PointerEventData eventData)
    {
        Vector2 localCursor;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(GetComponent<RectTransform>(), eventData.position, eventData.pressEventCamera, out localCursor);
        Rect r = RectTransformUtility.PixelAdjustRect(GetComponent<RectTransform>(), GetComponent<Canvas>());
        Vector2 ll = new Vector2(localCursor.x - r.x, localCursor.y - r.y);

        int x = (int)(ll.x / r.height * CountryImg.sprite.textureRect.height);
        int y = (int)(ll.y / r.height * CountryImg.sprite.textureRect.height);
        if (CountryImg.sprite.texture.GetPixel(x, y).a > 0) return false;
        else return true;
    }
public void OnPointerClick(PointerEventData eventData)
    {
        
        if(!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
           
        }
}

Если будет интересно, опишу подробнее. Если вкратце, то преобразовываем позицию из координат экрана в локальные координаты картинки, получаем позицию относительно центра картинки, вычисляем координаты пикселя картинки, достаем пиксель, проверяем на альфа-канал.

Все, огонь, запускаем!

image

Стоит запрет на чтение текстуры, находим спрайт картинки и выставляем ему следующие параметры:

image

Теперь все нормально, однако…

2-я проблема. Пиксель мы нашли, альфа-канал определили. Но под прозрачным слоем все равно находится другая страна.

Опять же на помощь приходит EventSystem, у которого есть свой Raycast с блэкджеком и gameObjecta’ми.

 List<RaycastResult> raycastResults=new List<RaycastResult>();
  EventSystem.current.RaycastAll(eventData, raycastResults);

Список объектов получили, теперь можно с этим работать:

 public void MayBeYouWantClickMe(List<CountryMap> ResultsCountryMap, PointerEventData eventData)
    {
        if (!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            ResultsCountryMap.Remove(this);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);
        }
    }
    public void OnPointerClick(PointerEventData eventData)
    {
        
        if(!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            List<RaycastResult> raycastResults=new List<RaycastResult>();
            EventSystem.current.RaycastAll(eventData, raycastResults);
            List<CountryMap> ResultsCountryMap = raycastResults.Select(x => x.gameObject.GetComponent<CountryMap>()).ToList();
            ResultsCountryMap.RemoveAll(x => x == null || x.gameObject==gameObject);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);

        }

Ну, вот и все, теперь даже если над непрозрачным рисунком будет даже 100 и более прозрачных, мы все равно увидим непрозрачный.

Приведу полный код скрипта:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Linq;
public class CountryMap : MonoBehaviour,IPointerClickHandler {
    Image CountryImg;
    Image SelectCountry;
    public event CountryMapEvent TapEvent;

    void Awake()
    {
        CountryImg = GetComponent<Image>();
        SelectCountry = transform.GetChild(0).GetComponent<Image>();
        SelectCountry.sprite = Resources.Load<Sprite>("Image/Countries/" + CountryImg.sprite.name);
    }
    private bool IsAlphaPoint(PointerEventData eventData)
    {
        Vector2 localCursor;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(GetComponent<RectTransform>(), eventData.position, eventData.pressEventCamera, out localCursor);
        Rect r = RectTransformUtility.PixelAdjustRect(GetComponent<RectTransform>(), GetComponent<Canvas>());
        Vector2 ll = new Vector2(localCursor.x - r.x, localCursor.y - r.y);

        int x = (int)(ll.x / r.height * CountryImg.sprite.textureRect.height);
        int y = (int)(ll.y / r.height * CountryImg.sprite.textureRect.height);
        if (CountryImg.sprite.texture.GetPixel(x, y).a > 0) return false;
        else return true;
    }
    public void MayBeYouWantClickMe(List<CountryMap> ResultsCountryMap, PointerEventData eventData)
    {
        if (!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            ResultsCountryMap.Remove(this);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);
        }
    }
    public void OnPointerClick(PointerEventData eventData)
    {
        
        if(!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            List<RaycastResult> raycastResults=new List<RaycastResult>();
            EventSystem.current.RaycastAll(eventData, raycastResults);
            List<CountryMap> ResultsCountryMap = raycastResults.Select(x => x.gameObject.GetComponent<CountryMap>()).ToList();
            ResultsCountryMap.RemoveAll(x => x == null || x.gameObject==gameObject);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);

        }
    }

    public void StopSelect()
    {
        StopAllCoroutines();
        SelectCountry.color = new Color32(255, 255, 255, 0);
    }
    public void StartSelect()
    {
        StartCoroutine(Selecting());
    }
    IEnumerator Selecting()
    {
        int alpha=0;
        int count = 0;
        while (true)
        {
            alpha = (int)Mathf.PingPong(count, 150);
            count = count > 300 ? 0 : count + 5;
            SelectCountry.color = new Color32(255, 255, 255, (byte)alpha);
            yield return new WaitForFixedUpdate();
        }
    }
}



А теперь бонус от решения задачки с помощью просмотра пикселя. Помните ту картинку, где мы ставили параметры спрайту? Так вот, есть там такая галочка Read/Write Enabled, именно благодаря ей мы можем получить доступ к пискселю. Как понятно из слова Write — не только для чтения.

Мы можем менять пиксели как нам угодно!

Пример, осветление спрайта:

Texture2D tex = CountryImg.sprite.texture;
        Texture2D newTex = (Texture2D)GameObject.Instantiate(tex);
        newTex.SetPixels32(tex.GetPixels32());
        for (int i = 0; i < newTex.width; i++)
        {
            for (int j = 0; j < newTex.height; j++)
            {
                if (newTex.GetPixel(i, j).a != 0f) newTex.SetPixel(i, j, newTex.GetPixel(i, j)*1.5f);
                
            }
        }
        
        newTex.Apply();
        CountryImg.sprite = Sprite.Create(newTex, CountryImg.sprite.rect, new Vector2(0.5f, 0.5f));

Было:

image

Результат:

image

На этом все. Очень надеюсь, что эта статья хоть как-то кому-то поможет. Если есть вопросы или замечания, пожалуйста, пишите комментарии.

Комментарии (26)


  1. Tutanhomon
    19.06.2015 16:38
    +1

    Делали аналогичное для хидден-игры, надо было чтобы спрайт по которому кликнули распадался на пикселы и превращался в партикл систему.
    А не поломается вся схема если спрайты в атласы сложить, uv координаты ведь придется считать относительно позиции спрайта в атласе?
    Второе, просто уточнение, мало ли кто не знает, что isReadable текстура в памяти занимает в два раза больше места.
    Третье, осветление записью в текстуру — это наверно не очень вариант, приходится оперировать большими данными в памяти, проще решить это шейдером и/или цветом спрайтов. Но как информация — несомненно полезно.


    1. Djonny_D Автор
      19.06.2015 21:14

      На счет игры, было бы очень интересно посмотреть, как вы это реализовали)
      Про атласы вопрос заставил врасплох, раньше не приходилось с ними работать( к сожалению), но на скорую руку собрал атлас из стран. И да, вы правы, схема ломается. Данный метод работает только, если текстурки отделены друг от друга.
      С шейдерами тоже пока углубленно не сталкивался, слишком зелен для этого.


      1. Tutanhomon
        20.06.2015 12:07

        Делал это в игре Dreamwoods 2, на ютубе можно найти трейлеры, там будет этот эффект (правда художник все упростил и заменил разноцветные частицы одним цветом). Делается просто. Готовится партикл система с нужными настройками физики. Затем с определённым шагом читаем пикселя со спрайта и по соответствующим координатам эмитируем партикл цвета этого пикселя, и запускаемых систему. Мне там ещё приходилось заставлять партикли лететь и складываться в картинку в другом месте.


    1. Djonny_D Автор
      20.06.2015 11:07

      В общем, на счет атласов, провел я не большое исследование, и сразу хочу сказать огромное спасибо, что направили в эту сторону! Уменьшились DrawCalls, да и памяти текстуры стали меньше кушать. Раньше я подозревал, что атласы использовать мудро, но слабо представлял почему.
      На счет работы схемы, нужно дописать пару строк, что бы все работало так же прекрасно, а именно:

          private bool IsAlphaPoint(PointerEventData eventData)
          {
              Vector2 localCursor;
              RectTransformUtility.ScreenPointToLocalPointInRectangle(GetComponent<RectTransform>(), eventData.position, eventData.pressEventCamera, out localCursor);
              Rect r = RectTransformUtility.PixelAdjustRect(GetComponent<RectTransform>(), GetComponent<Canvas>());
              Vector2 ll = new Vector2(localCursor.x - r.x, localCursor.y - r.y);
              //Найдем пиксель где начинается спрайт нужной страны в атласе - локальный (0,0)
              Vector2 pixelStart=new Vector2(CountryImg.sprite.texture.width*CountryImg.sprite.uv[2].x, 
                                              CountryImg.sprite.texture.height*CountryImg.sprite.uv[2].y);
              int x = (int)(ll.x / r.height * CountryImg.sprite.textureRect.height );
              int y = (int)(ll.y / r.height * CountryImg.sprite.textureRect.height);
              //вычесляем координаты относительно атласа
              pixelStart+=new Vector2(x,y);
              print("itog:" + pixelStart.x + " " + pixelStart.y);
              return IsAlphaPoint((int)pixelStart.x, (int)pixelStart.y);
          }
      


      Правда есть один нюанс касательно чтения пикселей из атласа. Если формат атласа используется Compressed(как я понял все разновидности) то будет возникать такие исключения:

      x == 0 && y == 0 && blockWidth == dataWidth && blockHeight == dataHeight
      UnityEngine.Texture2D:GetPixel(Int32, Int32)


      Следствием чего работать не будет (ну еще бы оно с ошибками работало…).
      Это очень печальное ограничение, текстура с форматом ARGB32 например будет занимать 16 Мб…а в памяти и того больше.
      Но благо есть ARGB16… в общем поджал атлас до 2 Мб. Смотрится хорошо, все работает, как надо.


  1. Darthman
    19.06.2015 17:38

    А я использовал политическую карту мира. Рисовал обычную, а страну определял по цвету пикселя соотвествующего на политической карте мира. Выбрать можно было любую страну с точностью до пиксела. Довольно удобно и чертовски просто.


    1. Djonny_D Автор
      19.06.2015 21:19

      Да, это упростило бы задачу, если бы не концепция дизайна игры.


      1. DmitryMry
        21.06.2015 07:25

        Для вашего случая можно было бы сделать подобно — обычная карта (одно цельное изображение с нормальными цветами), отображаемая на экране, и цветная (также одно цельное изображение), с которой будет происходить внутренняя работа. Проверка страны по координатам — посмотреть смещение относительно левого верхнего угла карты на экране и взять цвет пикселя с таким же смещением в раскрашенной карте. Таким образом узнали, что за страна. Для подсвечивания определённой страны, можно использовать фрагментный шейдер, в который дополнительно передавать текстуру раскрашенной карты и дополнительные параметры — цвет страны, которую нужно подсвечивать и тип подсвечивания. А шейдер проверяет цвет пикселя в раскрашенной карте и в зависимости от этого, либо рисует просто, либо с подкрашиванием, либо не рисует вообще (так можно сделать вариант, что рисоваться будет только одна указанная страна с необходимым подсвечиванием) — то есть, цветная карта используется просто как маска.


  1. Tutanhomon
    20.06.2015 12:18

    ещё один нюанс, совсем маленький. Не стоит использовать сравнение флотов на равенство «color.a ==0f». Рискуете получить непредсказуемые результаты на разных машинах, правильнее обращаться к Mathf.Approx (вроде так). Или сравнивать "< 0.01f" или "< Mathf.Epsilon"


    1. Djonny_D Автор
      20.06.2015 14:06

      Учту, спасибо!


  1. soulburner
    20.06.2015 15:51

    Вы меня простите, но все чему вы «учите» в этой статье — категорически неверно и нужно бить по рукам каждый раз, когда кто-то делает нечто подобное.

    1. Texture read/write очень сильно потребляет расход памяти, делает невозможным использование сжатия текстур, да и вообще — работает очень медленно. Для целей определения клика отлично подходит система полигональных коллайдеров.

    2. Осветление текстуры однозначно надо делать шейдером. Это просто в сотни (а то и тысячи) раз быстрее. Менять пиксели в текстуре — это вообще из ряда вон выходящая практика и применяется крайне редко, когда без нее никак (например, когда нужно рисовать мышкой на текстуре и т.п.)

    В результате вы написали тонну лишнего кода (все эти беганья по текстурам и сравнивание пикселей), где можно было обойтись одной строчкой кода «получить объект под мышкой». Да, придется обвести коллайдером спрайт. Но это не так страшно и быстро. Да и если очень хочется, есть автоматические тулзы.


    1. Djonny_D Автор
      20.06.2015 19:22

      1. Как я думаю, не обязательно это должно быть верным, если есть Texture read/write значит это зачем то нужно, и это нужно использовать. Пожалуйста, разъясните, почему и как это делает невозможным, сжимать текстуры, я сжал атлас стран до 2 Мб, при этом они имеют презентабельный вид, даже лучше чем раньше, до написания статьи. Да и на производительность, я не заметил, что сказывается. Если имеется ввиду что нельзя применить формат Compressed, то да я с вами согласен, нельзя. Но для моего случая это оказалось не критичным.

      2.Как я уже говорил в сторону шейдеров, возможности посмотреть не было, и да по примерам, это действительно было бы лучше. Но я привел все это как пример, мало ли кому пригодиться.


      1. soulburner
        21.06.2015 00:27

        Зачем и кому это (read/write) нужно — я написал. Это случай на самый-самый край, который используется только когда никак иначе нельзя. Вы прививаете себе, в главное — всем читающим изначально неверные привычки. Вы увеличили расход памяти в разы (если сравнивать со сжатым атласом, то получится раза в 4-8, в зависимости от формата сжатия), вы понизили производительность на порядок, и все это без какой-либо необходимости делать именно так. Кроме того, вы написали 3 десятка строк там, где можно было обойтись одной. Если вы говорите «я не почувствовал», то это исключительно из-за размера проекта.

        Хотите дальше забавать гвозди микроскопом — ок, я не буду переубеждать.

        Я лишь буду надеяться, что эта статья никому не пригодится и никто не воспримет ее как руководство к действию :)


    1. BasmanovDaniil
      21.06.2015 00:16

      Есть GraphicRaycaster, не надо коллайдеров.


      1. soulburner
        21.06.2015 00:31

        Тем более :)
        Мне просто пока не пришлось глубоко сталкиваться с новым uGUI, я старообрядец.


  1. yar3333
    20.06.2015 19:40

    Если не секрет, откуда вы взяли сами картинки (карту)?


    1. Djonny_D Автор
      21.06.2015 06:31

      Работа арт-отдела компании, в которой я работаю.


  1. Leopotam
    21.06.2015 14:59

    Можно вообще оттрассировать контуры стран в 2д/меш-колайдеры, а карту держать одной картинкой — максимальная производительность по рендеру, мало кода, неплохая производительность по физике на unity >=5.0.


    1. Leopotam
      21.06.2015 21:48

      Прочитал первый абзац по диагонали, пропустил про выделение цветами. Если нужно выделение территории цветом, то к предыдущему комменту можно добавить следующее — раз уже есть мешколайдеры, то при детекте фокуса достаточно брать этот меш и рендерить его с полупрозрачностью поверх существующей карты с подкраской нужным цветом. Блендинг меша одной страны будет все-равно дешевле карты, работающей 100% в транспаренте.


  1. Gasparfx
    21.06.2015 23:40

    Какой то список вредных советов, не иначе. Первая картинка особенно пугает. Использовать канвас для такой задачи просто странно. Я бы сделал все так:
    1. Страны — это спрайты, рендерятся СпрайтРендерами без всяких канвасов.
    2. К СпрайтРендерам добавляется ПолигонКоллайдер2Д и он автоматически принимает форму спрайта, ничего не нужно делать вручную. Если сгенерился не очень аккуратный, можно сразу подредактировать.
    3. Вешаем на этот спрайт свой скрипт, он имплементирует iPointerClickHandler.
    4. На камеру вешаем PhysicsRaycaster2D, в сцену добавляем объект EventSystem. После этого скрипт карты начинает принимать сообщения о кликах.
    5. Если нужно подсветить какую то страну — пишем простенький шейдер и накладываем материал на этот спрайт.

    В итоге кода то и не будет в программе. Только обработчик клика. Ну и шейдер осветления, если действительно нужен.
    Read/Write — не просто так вынесли в дополнительные настройки — его не стоит использовать просто так.