Привет, меня зовут Юрий Грачев, я программист из студии Whalekit — автора зомби-шутера Left to Survive и мобильного PvP-шутера Warface: Global Operations. Кстати, именно о его технологиях мы и поговорим подробнее далее.

Речь пойдет о получении сжатых атласов в рантайме. Для начала мы выясним, что вообще такое атласы, для чего они нужны и какие требования предъявляются к исходным текстурам. Затем рассмотрим самый простой способ собрать в рантайме атлас и оценим результат с технической точки зрения. После этого я расскажу о наших экспериментах с компрессией в рантайме. Наконец, мы посмотрим, что общего у разных алгоритмов сжатия изображений, и подойдем к тому, ради чего статья и задумывалась: поговорим о нашем альтернативном подходе, при котором вообще не придется заниматься пережиманием пикселей в рантайме для получения сжатого атласа.

В паре слов о проекте

Как я уже говорил, речь пойдет о Warface: GO. Это командный экшен-шутер, кор-геймплей которого — PvP-сражения 4-на-4 игрока. 

Игрокам доступны сотни заменяемых элементов экипировки. Каждый персонаж представляет собой набор из восьми скинованных мешей, которые в Unity не батчатся из коробки. У каждого меша есть пара уникальных текстур: диффузная и нормалка. Вдобавок к этому, у каждого персонажа есть два взаимозаменяемых оружия, а это еще как минимум один рендерер.

В итоге мы получаем, что каждый персонаж в игре рисуется с использованием минимум 18 drawcall’ов, из которых 9 уходит на основной кадр и 9 — на отрисовку shadow maps. В сумме мы получаем аж 144 drawcall’ов — и это только на персонажей!

А вот так в игре выглядят персонаж и его экипировка до и после ее смены:

Атласы: что это такое и зачем они нужны

Так как мы поддерживаем iPhone 6, а на старте разработки замахивались даже на 5s, нам было важно избавляться от такого количества drawcall’ов. Обычно на слабых девайсах наши проекты упираются именно в CPU, который ставит эти самые drawcall’ы в очередь команд, а не в GPU, который затем их выполняет. 

Чтобы снизить количество drawcall’ов, мы вручную объединяем в один меш геометрию элементов экипировки, из которых состоит наш персонаж. И чтобы это имело смысл, нужно объединить не только геометрию, но и текстуры, чтобы впоследствии можно было использовать один материал с одним комплектом текстур.

Тут на помощь и приходят атласы: без них, даже объединив геометрию, мы будем все еще вынуждены рисовать элементы экипировки отдельными drawcall’ами, между которыми будет происходить переключение текстур. Чаще всего атласы можно встретить созданными художниками вручную при подготовке статического контента — но мы-то хотим делать это в рантайме, ведь персонаж у нас собирается динамически самим игроком из предопределенных элементов.

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

Начиная работу с атласами, нужно иметь в виду требования и ограничения, предъявляемые к исходным текстурам:

  • Мы вынуждены доставлять исходные текстуры на девайс пользователя обязательно в сжатом виде, иначе они будут занимать слишком много места в памяти конечного устройства;

  • Если в материале есть два текстурных слота, которые адресуются одним и тем же набором UV-координат, нужно позаботиться о том, чтобы пропорции соответствующих текстур были одинаковыми — иначе один из атласов может неправильно собраться и/или не соответствовать второму, а исходные текстуры при апскейлинге или даунскейлинге могут отличаться по качеству от соседей по атласу.

Немаловажно вспомнить и про color bleeding. Каждый раз, когда мы собираем атлас, мы вынуждены с ним бороться. Ниже показан пример с включенной и выключенной билинейной фильтрацией:

Как мы видим, при включенной фильтрации границы текстур внутри атласа начинают размываться, и цвета между текстурами смешиваются засчет того, что билинейная фильтрация берет соседние пиксели на границе двух текстур и их интерполирует. Бороться с этим можно довольно простым методом — сделав запас внутри текстуры для UV shell. Он не должен примыкать к границам текстур.

Наивная реализация текстурного атласа

Теперь, когда мы разобрались с исходными текстурами, давайте попробуем их объединить и рассмотрим самый простой метод, как это сделать:

  • Берем пачку текстур;

  • Готовим лэйаут этих текстур внутри атласа — как вариант, можем воспользоваться методом Texture2D.GenerateAtlas;

  • Создаем RenderTexture в формате ARGB32;

  • Blit'им наши текстуры в атлас в соответствии с подготовленным лэйаутом;

  • Исправляем UV-координаты нашей комбинированной геометрии;

  • Получаем на выходе профит (ака собранный воедино персонаж).

Визуально результат не отличается от того, какой мы бы увидели в случае с разрозненным набором мешей и текстур, но с технической точки зрения персонаж становится единым мешем, который рисуется за один draw call.

Чтобы показать наглядно, как это влияет на показатели, я запустил тестовую сцену с комбинированием и без комбинирования и получил следующие результаты:

Видимых мешей стало меньше в разы, количество батчей тоже сократилось почти в два раза. Также снизилось время, затраченное в render thread’е.

Получившаяся рендер-текстура формата ARGB32 занимает много памяти (ОЧЕНЬ много памяти). Можно, конечно, снизить разрешение, тогда она будет занимать меньше памяти, но и детали изображения мы потеряем. Зато такая текстура может быть любых пропорций и размера, имеет широкую поддержку и работает везде.

Стоит учесть, что не все текстуры можно собрать таким методом в атлас. Могут возникнуть проблемы при попытке объединения исходных текстур с закодированными в цвет данными. Реинтерпретация цвета наверняка приведет к невозможности декодировать данные обратно. Зато та же реинтерпретация цвета позволяет blit’ить в атлас исходные текстуры любого формата. То есть, можно добавлять атлас разнородные текстуры.

И все-таки проблема занимаемого объема памяти таким атласом и связь этого объема с разрешением перевешивает абсолютно все, что может быть сказано после этого. Так что, поняв, что такой результат нас не очень-то устраивает, мы стали думать, какие еще варианты у нас есть. И первая очевидная мысль, которая нас посетила — попробовать runtime compression.

Runtime compression

Первым делом мы нашли на просторах GitHub библиотеку под названием Unity.PVRTC и немного поэкспериментировали с ней. Библиотека заработала сразу из коробки, но очень медленно. По исходному коду сразу было видно, что она очень сырая. Нам пришлось достаточно сильно ее переписать, применяя даже Burst и Unity Jobs. Как результат, мы снизили время компрессии с 4 с до 220 мс для одной 2K-текстуры на iPhone 6. 

Как ни странно, этого было все еще недостаточно. Продюсеры были недовольны тем, что, применяя ARGB32-атласы и эту рантайм компрессию, мы увеличивали суммарное время старта миссии на несколько секунд, что плохо влияло на UX. Более того, мы планировали поддержку Player backfill — это когда новый игрок может присоединиться к уже начавшейся игровой сессии. Фича требовала выполнения такой же компрессии в середине игровой сессии на каждом пользовательском устройстве для смены «отвалившегося» персонажа на нового.

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

Повертев эту библиотеку, мы продолжили искать способы получения нормального атласа и подумали: а что, если попробовать рассмотреть алгоритмы сжатия с другой стороны? Родилась идея изучить подробнее подноготную разных алгоритмов сжатия: ASTC, PVRTC, ETC, BC (DXT). Мы надеялись найти какие-то подсказки, как нам реализовать сжатие в рантайме более эффективно. И мы нашли.

Эти разные алгоритмы сжатия

Все перечисленные выше форматы — ASTC, PVRTC, ETC, BC (DXT) — работают с блоками пикселей или с пакетами. Каждый такой блок кодируется в один или два 64-битных числа (long/int64), при этом все блоки в памяти лежат линейно и построчно для всех форматов, кроме PVRTC, в котором используется Z-order (кривая Мортона). MIP’ы во всех форматах (включая PVRTC) тоже лежат линейно от самой большой текстуры к самой маленькой.

На примере DXT1/BC1 рассмотрим, что представляет из себя блок пикселей:

Изображение делится на одинаковые квадратики размером 4×4 пикселей, после чего из этих 16 пикселей выбираются два опорных цвета, и каждый кодируется в 16 бит. В дополнение к этим двум опорным цветам строится матрица индексов, которая позволяет получить из них все 16 пикселей с некоторым приближением.

Как я уже говорил, эти блоки лежат либо линейно, либо в Z-последовательности следующим образом:

Отличие (и, наверное, преимущество) PVRTC здесь заключается в том, что использование Z-последовательности увеличивает локальность области данных в кэше процессора, так что становится больше кэш-хитов, чем кэш-миссов при работе с областью изображения, которая обычно все-таки двумерная, а не одномерная. То есть, ситуаций, в которых нужна строка пикселей/блоков, гораздо меньше, чем в которых нужен какой-то прямоугольный участок тех же данных.

Вооружившись этим багажом знаний, мы предприняли попытку собрать атлас из таких блоков, просто перекладывая их в памяти. Блочная природа этих данных и независимость блоков друг от друга сыграли нам на руку: такими блоками можно жонглировать, читая их как обычные long’и (или пары long’ов).

Наша реализация PVRTC-атласа

Чтобы все это «взлетело», нам потребовалось ввести несколько дополнительных требований к исходным текстурам:

  • Во-первых, текстуры должны быть квадратными и в степени двойки в виду того, что алгоритм лэйаута у нас довольно хитрый, да и сама Unity не делает MIP уровней, если текстура не в степени 2.

  • Во-вторых, у всех исходных текстур должны быть одинаковые настройки импорта. Это продиктовано тем, что объединять блоки в атласе таким образом можно только с учетом однородности входящих данных.

  • В-третьих, мы поддержали только ASTC блоки размеров 4×4 и 8×8. Тут сыграл не последнюю роль наш алгоритм расположения текстур в атласе. Но на самом деле основной причиной было нежелание бороться со всякими бортиками. Ведь текстура степени двойки при использовании ASTC 10×10, например, нацело не делится на размер блока. В итоге по краю текстуры остаются ASTC блоки, заполненные релевантными данными лишь частично. С ними как раз и непонятно, что делать. В идеале надо было пережимать текстуры, от чего мы как раз пытались уйти.

  • И последнее — включение Read/Write Enabled галочки в импортере всех исходных текстур, чтобы мы могли получить доступ к пикселям на стороне CPU.

Теперь на примере псевдокода давайте посмотрим, как выглядит создание такого атласа. 

У нас есть некая функция, на входе которой — набор исходных текстур, формат и лэйаут. Внутри функции мы создаем Texture2D нужного нам размера и указанного формата с поддержкой мипов:

public static Texture2D GenerateAtlas(Texture2D[] sources,
                                      TextureFormat format,
                                      Layout layout)
{
  var atlas = new Texture2D(4096, 4096, format, mipChain: true,
                            linear: false);

Хочется отметить, что тут создается именно Texture2D, а не RenderTexture, как в случае наивной реализации.

Затем мы получаем доступ к области памяти с пикселями этой текстуры через обобщенный метод GetRawTextureData, используя long в качестве типа данных:

NativeArray<long> atlasData = atlas.GetRawTextureData<long>();

Теперь можно в этот массив писать блоки. Мы перебираем все наши исходные текстуры и получаем ссылки на соответствующие массивы блоков:

for (int srcIndex = 0; srcIndex < sources.Length; ++srcIndex)
{
  var source = sources[srcIndex];
  NativeArray<long> sourceData = source.GetRawTextureData<long>();

Производим расчеты смещений и копируем блоки исходных текстур в массив блоков нашего атласа:

Rect sourceRect = layout.GetRect(srcIndex);

for (int mip = 0; mip < source.mipmapCount; ++mip)
{
  MemoryRect memRect = GetMemoryRect(format, 4096, 4096, sourceRect,
                                     source.width, source.height, mip);
  CopyMemoryData(sourceData, atlasData, format, memRect);
}

Тут Rect задает расположение отдельной текстуры на атласе. А MemoryRect — это сущность, которая отвечает за расчет всех смещений, размеров, отступов и шагов.

Для примера — при линейном расположении блоков функция может выглядеть так:

public static void CopyMemoryDataLinear(NativeArray<long> source,
                                        NativeArray<long> destination,
                                        MemoryRect memRect)
{
  for (int y = 0; y < memRect.blocksY; ++y)
  for (int x = 0; x < memRect.blocksX; ++x)
  {
    int srcOffset = memRect.GetSliceOffsetSrc(x, y);
    int dstOffset = memRect.GetSliceOffsetDst(x, y);
    destination[dstOffset] = source[srcOffset];
  }
}

В конце обязательно вызываем метод Apply, который применит загруженные данные на стороне графического API:

  atlas.Apply();
Код целиком
public static Texture2D GenerateAtlas(Texture2D[] sources, TextureFormat format, Layout layout)
{
  var atlas = new Texture2D(4096, 4096, format, mipChain: true, linear: false);
  NativeArray<long> atlasData = atlas.GetRawTextureData<long>();

  for (int srcIndex = 0; srcIndex < sources.Length; ++srcIndex)
  {
    var source = sources[srcIndex];
    NativeArray<long> sourceData = source.GetRawTextureData<long>();

    Rect sourceRect = layout.GetRect(srcIndex);

    for (int mip = 0; mip < source.mipmapCount; ++mip)
    {
      MemoryRect memRect = GetMemoryRect(format, 4096, 4096, sourceRect, source.width, source.height, mip);
      CopyMemoryData(sourceData, atlasData, format, memRect);
    }
  }

  atlas.Apply();
  return atlas;
}

public static void CopyMemoryDataLinear(NativeArray<long> source, NativeArray<long> destination, MemoryRect memRect)
{
  for (int y = 0; y < memRect.blocksY; ++y)
  for (int x = 0; x < memRect.blocksX; ++x)
  {
    int srcOffset = memRect.GetSliceOffsetSrc(x, y);
    int dstOffset = memRect.GetSliceOffsetDst(x, y);
    destination[dstOffset] = source[srcOffset];
  }
}

Если вы точно знаете, что больше в атлас никакая текстура не уместится, или вы логически завершили добавление текстур в этот атлас, то лучше вызывать метод Apply с дополнительным параметром:

atlas.Apply(false, makeNoLongerReadable: true);

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

В результате выполнения данного кода на выходе мы получаем объединенную Texture2D того же формата, что и исходные текстуры.

Из плюсов данного решения можно отметить следующее:

  • Мы получаем атласы более высокого разрешения;

  • Они занимают гораздо меньше места в памяти per pixel;

  • Мы избавляемся от артефактов двойной компрессии;

  • Отсутствует bleeding внутри мипов (а мог бы быть, если бы мипы создавались на основе уже готового атласа)

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

А теперь рассмотрим наглядно разницу двух получающихся при разных подходах атласов:

Наш атлас имеет разрешение в 4K в силу того, что исходные текстуры персонажа не влезали в 2К. Видно, что он весит чуть больше «наивного» ARGB32 атласа, но это большое разрешение по итогу играет нам на руку, о чем я еще расскажу подробнее позже. Тут можно оценить пропорцию разрешений, чтобы понять потенциальную разницу в качестве.

Мы можем убедиться в правильности подхода, сравнив наш новый вариант атласа с наивной реализацией:

И еще кое-что…

Поскольку размер атласа в 4K и большое количество пустого места в нем нам это позволяли, мы попробовали объединить в один атлас сразу несколько персонажей и сделали страничную имплементацию.

Для этого мы сначала собираем со всех персонажей все текстуры, которые нужно добавить в атлас. Затем разделяем эти текстуры на группы, каждая из которых должна уместиться в одну 4K текстуру. При этом необходимо соблюдать простое правило: каждый персонаж должен попадать целиком на одну страницу, иначе полностью переносим его на новую. При таком подходе повторяющиеся текстуры можно переиспользовать, если остальные текстуры персонажа находятся на той же странице.

По нашим подсчетам, таким образом мы должны были получать не больше 3-4 страниц атласа в худшем случае, но реальность даже превзошла наши ожидания: на деле мы никогда не видели больше двух страниц для всех персонажей на сцене.

Итоги

Что нам дал такой механизм объединения текстур в атласы?

Рассмотрим на примере PVRTC/iOS. Суммарный объем памяти, который занимают наши атласы — 21 MB против прежних 46 MB для атласов в формате ARGB32. Время на генерацию двух PVRTC страниц сократилось до 70 мс вместо 8×220 мс времени, затраченного только на компрессию (без учета подготовки ARGB32 рендер текстуры). Текстуры стали большего разрешения, теперь они не пережимаются никакой двойной компрессией, и появилась возможность их переиспользовать — то есть, избавиться от части дубликатов в видео-памяти.

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


  1. SadOcean
    06.01.2022 14:18
    +1

    Крутая реализация, спасибо за подробное объяснение.
    А как обрабатывали меши?
    Тоже руками сшивали и переписывали индексы и UV?
    Или есть еще какие то возможности хаков?


    1. Altair4Ru Автор
      06.01.2022 15:37
      +1

      Мы тривиально объединяли несколько мешей в один копированием содержимого и исправлением индексов и UV, да. Без каких-либо дополнительных хитростей.


  1. SergeySmirnovDesign
    06.01.2022 14:25

    "сравнив наш новый вариант атласа с наивной реализацией" - Наивной? :)


    1. SergeySmirnovDesign
      06.01.2022 14:26

      Интересная статья, спасибо!


    1. Altair4Ru Автор
      06.01.2022 15:39
      +1

      Слово "наивный" тут используется в значении "простой".


      1. Leopotam
        06.01.2022 18:54
        +3

        Скорее "решение в лоб".



  1. ufna
    06.01.2022 14:43

    Спасибо что оформили в виде публичной статьи, это очень крутое решение!


  1. holydel
    06.01.2022 15:42

    Отличная статья, спасибо!

    Хранение блоков pvr текстуры сразу в z curve order-е, скорее всего, только сэкономит одно преобразование image layout-а на этапе загрузки. На семплинг это не должно вляить (в теории).


  1. viruseg
    06.01.2022 20:01

    А Graphics.CopyTexture разве не делает то, что вам было нужно?


    1. Altair4Ru Автор
      06.01.2022 21:16

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

      И я сейчас могу ошибаться, но мне помнится, что в момент изучения этой функции в качестве альтернативы, меня гугл привел к мысли, что на мобильных платформах она в принципе не работает со сжатыми форматами.


  1. MaxEdZX
    07.01.2022 00:40
    +1

    Интересное решение.

    Мы решали немного другую, но схожую задачу в Pathfinder: Wrath of the Righteous. У нас атлас изначально собирается из несжатых текстур, и кроме того атласы бывают разного размера (в зависимости от настроек качества графики), поэтому по такой технологии как у вас их вроде бы трудно будет собрать.

    Поэтому мы ограничились сжатием в рантайме в DXT как раз, портировав на Burst StbDxtSharp. То есть сначала атлас собирается в рендертекстуру (плюс там ещё всякая покраска хитрая, что тоже мешает из заранее сжатых текстур атлас собрать), потом мы из неё GPU Readback'ом читаем результат и сжимаем его (асинхронно), после окончания сжатия (на PC и консолях оно получается типа 25мс на 1024 текстуру) - удаляем несжатую версию.

    Я всё собираюсь нашу версию DXT-сжатия выложить в открытый доступ, но руки не доходят оформить всё удобно.


    1. Altair4Ru Автор
      08.01.2022 03:00

      Хочется отметить, что для реализации "настроек качества графики" можно просто пропускать несколько мипов и объединять не с нулевого, а с 1-2-3-etc.

      А касаемо dxt компрессии - у вас все-таки ПК и консоли, а не мобилки. Ваши 25мс превращаются у нас в куда более значимые цифры на low-end'ах.

      Покраска вставляет палки в колеса, да, но часто ее можно сделать в шейдере арифметикой или текстурной маской. С ней я тоже уже намучался в нескольких проектах.

      А чтобы не делать медленный readback, я бы посоветовал перевести компрессию на compute, чтобы все ресурсы оставались на стороне GPU. Я где-то уже читал про DXT на GPU в рантайме (пример, пример2, но это просто беглый поиск, а не то, что я читал).


      1. MaxEdZX
        08.01.2022 15:28

        Хм, да, согласен насчёт объединения мипов, сработает пожалуй.

        На GPU было бы здорово сделать, я читал что-то от NVidia (кажется) тогда на эту тему, но у нас был свободный generalist (я), а свободного граф. программиста не было, поэтому я пошёл тем путём, который понимаю без долгого въезжания в тему. Нас скорость пока устраивает (пара секунд на фоне общей загрузки большого уровня погоды не делают), но буду держать в этот вариант в уме, на случай если когда-нибудь время появится.


  1. horror_x
    07.01.2022 06:48
    -3

    Эх, а когда-то это было очевидным инженерным решением. А теперь с разработчиками с «ассетным» мышлением зачастую сложно обсуждать трюки, выходящие за рамки стандартных возможностей Unity или UE.


    1. scar289
      07.01.2022 08:08
      -1

      Эх, а когда-то это было очевидным инженерным решением.

      Скучайте по временам когда ваши "уникальные" навыки были кому-то нужны, а теперь вам приходиться ныть на форуме, что ваши навыки потеряли актуальность?

      А теперь с разработчиками с «ассетным» мышлением зачастую сложно обсуждать трюки, выходящие за рамки стандартных возможностей Unity или UE.

      Вы их не обсуждайте. Статья и так рассказывает как реализовать это "очевидное инженерное решение", а вы просто припёрлись в тред с криками "удаляйте статью, таким умным и уникальным разработчикам как я, такие статьи не нужны".

      Как насчет того, что бы пройти мимо и не высказывать своё никому не нужное мнение о том, что все вокруг тупее вас?


      1. horror_x
        07.01.2022 17:37
        -1

        вы просто припёрлись в тред с криками «удаляйте статью, таким умным и уникальным разработчикам как я, такие статьи не нужны».
        Ого, что ещё за меня додумаете?

        Скучайте по временам когда ваши «уникальные» навыки были кому-то нужны, а теперь вам приходиться ныть на форуме, что ваши навыки потеряли актуальность?
        Нет, просто сетую на то, что из решения интересных задач это становится обыкновенной рутиной, где даже такие вещи как в статье выделяются на фоне.

        Вы их не обсуждайте.
        Что ещё мне нельзя делать?


  1. eternum
    07.01.2022 10:29

    В юнити, вроде, виртуальные текстуры завезли. Или это не оно? Или там ограничения какие?


    1. Altair4Ru Автор
      08.01.2022 03:01

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


  1. arTk_ev
    07.01.2022 20:52

    Текстурные массивы разве не поддерживает gles 3.1? Они куда лучше.


    1. Altair4Ru Автор
      08.01.2022 03:19

      Интересно, почему речь про gles 3.1? Я писал в статье про iphone 6 и даже 5s на старте, а там нет поддержки текстурных массивов. На Андроиде наверняка много устройств современных поддерживает 3.1, но мы целимся на 3.0. Более того, до прошлого года у нас еще и gles2 был в ходу.

      Помимо этого у текстурных массивов есть как плюсы, так и минусы. Кесарю кесарево. Почему вдруг "они куда лучше" так вот безапелляционно? Они, к примеру, требуют одинаковых размеров исходных текстур. Если Вы посмотрите на атласы из статьи, то заметите, что там текстуры разного размера (перчатки, ботинки, даже одна голова есть). Приводить все к одному размеру - это будет сильно разная плотность пикселей на разных участках персонажа, будет слишком бросаться в глаза.

      Если что, я даже сослался прям в тексте на статью Пиксоников про текстурные массивы - там можно почитать и про ограничения в том числе.


  1. VXP
    08.01.2022 05:37

    Left to Survive

    Warface: GO

    Вижу, фантазией в именовании продуктов ваша компания не настолько отличается, как в профессионализме программистов)