Unity3D симулятор “Smart snake with stereo vision”

Автор: | 11.02.2019

Постановка задачи
Движение змейки к цели по прямой
Установка камер на глазах змейки
Поток изображений с камер
Формализация движения в обход преград
Считывание и анализ точек изображений
Определение траектории движения
Полезные ссылки

Постановка задачи

В статье Игра “Snake” на Unity3D было рассмотрено, как создать игру «Змейка».

Змейка двигается вперед и меняет направление под управлением клавиш влево/вправо. Задача игрока — обойти препятствия и направить змейку к яблоку.

Ниже исследуются возможности создания на основе этой игры симулятора интеллекта змейки со стереозрением.

В чем конкретно заключается задача? При запуске приложения змейка самостоятельно (без управления игроком) должна двигаться к яблоку в обход всех преград.

Для реализации задачи необходимо:

  1. Направить змейку к цели — по прямой.
  2. Установить  камеры в глазах змейки.
  3. Обработать изображения от камер.
  4. Менять траекторию движения для обхода преград.

Движение змейки к цели по прямой

За движение змейки отвечает скрипт Player, который прикреплен к голове (Head). Объявляем в  скрипте открытую переменную  target.  В функции Update() вызываем метод  transform.LookAt(target):

...
public class Player : MonoBehaviour
{

public Transform target;
...
 public void Update()
 {
...
 transform.LookAt(target);
 _controller.Move(transform.forward * speed * Time.deltaTime /* * vertical*/);
}
...

В Unity инициализируем  переменную target объектом Food (перетаскиваем объект из окна Hierarchy к параметру Target в окне Inspector).

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

При этом управление змейки клавишами влево/вправо будет заблокировано. После столкновения с препятствием (кубиком) движение заканчивается (Game Over!). Наша задача — научить змейку самостоятельно обходить препятствия.

Установка камер на глазах змейки

В игровом приложении  «Змейка» установлена только одна камера. Она расположена над полем и нацелена на голову змейки. Последнее прописано в скрипте LookAt, который прикреплен к камере.

Наличие на сцене только одной камеры не решает поставленной задачи симуляции стереозрения. Для создания стереопары добавим на сцену еще 2 камеры — Camera_right и Camera_left . Сделаем камеры  дочериными (child objects) к глазам змейки.

Глаза должны располагаться на голове строго симметрично относительно оси Z головы.

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

Таким образом мы определили начало вектора вектор взгляда (главной оси камеры). Теперь осталось определить конец вектора (направление взгляда — target) камер.  Для этого прикрепим скрипт LookAt к каждой из камер (Camera_right и Camera_left ), а параметр Target для этих камер инициализируем яблоком (объектом Food).

Итак, у нас есть 3 камеры. На основе описания из Справочника по камерам настроим и разместим рендеринг камерами на сцене следующим образом:

На рисунке видим 3 окна — вид с главной камеры (основное окно), вид с левой камеры (левое окошко сверху) и вид с правой камеры (правое окошко сверху).

Запускаем приложение (Play). Получаем вот такой результат:

Поток изображений с камер

Стереозрение —  трехмерное представление окружающего мира по изображениям от двух камер, работающих синхронно. Мы пока смоделировали только  виды на  окружающий мир с различных точек. А нам необходимо получить изображения, подобные тому, которое получаем от цифровой камеры — растровая картинка в формате RAW. Как перейти от вида к изображению, используя возможности Unity3D?

Для начала нам нужно создать Render Texture — это особый тип текстуры, которая обновляется во время выполнения. В папке Assets добавляем папку CamTxtra, жмем правую кнопку, затем Create -> Render Texture и создаем 2-е текстуры с именами Left и Right.

Для каждой из камер (Camera_left и Camera_right) в свойство Target Texture перетаскиваем текстуры с соответствующими именами.

После этой операции со сцены исчезает окно вида от камеры, поскольку вид будет теперь рендериться в текстуру, а не в экран. Этот факт отобразится и на изображениях текстур в папке папке CamTxtra, а также в окне Inspector при выделении текстур.

Обратите внимание на то, как вид будет теперь рендериться в текстуру.  В текстуру Left вид рендерится полностью занимая окно текстуры, а в текстуре Right вид занимает только часть окна. Чтобы вид занимал окно текстуры полностью необходимо в окне Hierarchy выделить камеру (объект Camera_right), при этом установить  в окне Inspector ее свойства Viewport Rect к указанным на рисунке значениям:

В дальнейшем это будет иметь важное значение при считывании точек с изображений Render Texture.

Чтобы бы видеть на сцене текущие картинки текстур создадим 2-а объекта Canvas (GameObject > UI > Canvas) с именами Canvas_left и Canvas_right.  Далее добавляем  объекты RawImage (GameObject > UI > RawImage) и делаем их «дочериными» для каждого из объектов Canvas. В параметр Texture каждого из объектов RawImage  перетаскиваем соответствующую текстуру (Left и Right).

В Inspector помощью инструмента Rect Transform для объектов RawImage позиционируем картинки в наиболее удобное место на сцене. Это проще всего сделать с включенной вкладкой Game.

Картинки от камер расположены на сцене соответственно: слева — картинка от камеры левого глаза, справа — картинка от правого глаза.

Запускаем приложение (Play) и видим, как меняются картинки с камер, пока змейка перемещается.

Результат почти тот же, что и был получен ранее для видов с камеры. Однако, представление камеры на текстуре не отображает весь просмотр камеры (Width * Height), а только лишь квадратный фрагмент от изображения камеры с размером (Height * Height).

Можно перейти от квадрата к прямоугольнику. Для этого необходимо установить одинаковое значение отношения (Width/Height) между свойствами Size прикрепленной к камере текстуры  и свойствами RectTransform объекта RawImage. Например, стандартное Width/Height = 16/9, Size можно установить на 256/144,  а RectTransform — на 1280/720 (RectTransform  можно масштабировать до любого размера, который нужен).

Пока оставим квадратную картинку, поскольку обработка RAW-файла с размером, например, 256*256 или 512*512 проще.

Формализация движения в обход преград

Известно множество факторов, которые позволяют ориентироваться в 3D мире (см. The human eye’s understanding of space for Augmented Reality). Не будем сразу же пытаться «объять необъятное», а выберем пару условий-признаков, которые позволяет относительно просто решить нашу задачу.

Исходная информация — картинки с камер глаз.

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

Внимательно посмотрим на 2-а рисунка с GIF-анимацией, на которых отражены критические моменты для принятия решения.

На 1-м рисунке змейка касается кубика, что приводит к «Game Over!». На 2-м рисунке змейка проходит мимо кубика в непосредственной близости от него.

Выделим из GIF-анимации  кадры, по которым может быть непосредственно принято решение изменить (или нет) траекторию движения змейки.

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

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

Если на картинке с камеры левого или правого глаза точка 1 имеет цвет кубика, то змейка поворачивает влево (вправо), если того же цвета и точка 2  (точка 3). В противном случае змейка продолжает двигаться к яблоку по прямой.

Более простым языком: точка 1 определяет, перекрывается ли путь к яблоку кубиком,  а точка 2 (3) определяет, что кубик находится достаточно близко, чтобы выполнить при необходимости маневр змейкой в сторону.

Считывание и анализ точек изображений

На данном этапе наша задача сводится к определению цвета указанных выше 3-х точек. К сожалению, нет возможности напрямую считывать точки изображений Render Texture. Но можно сделать это опосредовано:

  • активное изображение Render Texture записывается в буферную память (выполняется «скриншот»);
  • из буферной памяти изображение читается в созданный для этого объект Texture2D;
  • из Texture2D получаем цвет нужного пикселя.

Создаем скрипт ReadPixels, в котором выполняются указанные действия.

using UnityEngine;

public class ReadPixels : MonoBehaviour
{
 //public Material apple;
 public Camera cam; // наша камера
 Texture2D tex;
 Color col;
 float vis;
 
 void Start()
 {
  tex = new Texture2D(cam.targetTexture.width, cam.targetTexture.height, TextureFormat.RGB24, false);
 }

void Update()
 {
 // делаем RenderTexture камеры cam активным
 // при этом RenderTexture записывается в буфер
     RenderTexture.active = cam.targetTexture;
//Из буфера читаем RenderTexture.active в Texture2D
    tex.ReadPixels(new Rect(0, 0, tex.width, tex.height), 0, 0);
// обновляем изменения 
    tex.Apply();
//Достаем цвет центрального пикселя из Texture2D
    col = tex.GetPixel(tex.width / 2, tex.height / 2);
// Определяем цветовой признак пикселя
//vis = (col.r + col.g + col.b) / 3;
   //vis = col.r;
   vis = col.r/col.g;
//Debug.Log(apple.color);
 if (vis > 10)
 {
 Debug.Log("SOS!!!"); // сообщение в консоль Unity
 //UnityEngine.SceneManagement.SceneManager.LoadScene("GameOver");
 }  
 //Debug.Log("vis" + vis); 
 //Debug.Log("tex_height" + tex.height/2); 
 //Debug.Log("Render_height" + RenderTexture.active.height/2);
 }
}

Скрипт ReadPixels прикрепляем к объекту сцены Left_eye. В окне Inspector к параметру Cam  перетаскиваем объект Camera_left.

Этот скрипт используем для исследований, какой цветовой признак vis будет наиболее избирательным при определении точек кубика. Какие могут быть альтернативные варианты? Это:

  • среднее значение компонентов цвета vis = (col.r + col.g + col.b) / 3;
  • один из компонентов цвета, например для красного яблока наиболее характерный col.r;
  • относительный цвет, например col.r/col.g.

Были выполнены запуски приложения с выводом значений цветовых признаков в окно консоли. Наиболее избирательным оказался последний вариант (vis = col.r/col.g), поскольку он наименее зависим от освещения сцены. Кроме этого, для цвета  кубика, который в приложении принят по умолчанию, составляющая цвета col.g =0, следовательно  col.r/col.g -> бесконечность. Достаточно для однозначного определения точек кубика условия vis > 10 .

Определение траектории движения

В предыдущем разделе с помощью скрипта ReadPixels были проведены исследования, которые позволили определить признак для идентификации точек кубика. Теперь осталось реализовать алгоритм обхода преград змейкой (см. Формализация движения в обход преград).

Скрипт ReadPixels открепляем от объекта сцены Left_eye, он свою задачу уже выполнил. Основная часть кода из этого скрипта используется для модификации скрипта Player, в котором контролируется перемещение змейки. На рисунке демонстрируется фрагмент запуска приложения, где змейка самостоятельно выбирает траекторию движения в обход препятствий. Модифицированный скрипт Player приводится ниже.

Модифицированный скрипт Player

В скрипте красным выделены  строки кода, которые имеют непосредственное отношение к обходу преград.

using UnityEngine;

[RequireComponent(typeof(CharacterController))]

public class Player : MonoBehaviour
{
 public Camera camleft; // левая камера
 public Camera camright; // левая камера
 Texture2D tex;
 Color col;
 float vis1_L, vis2_L, vis3_L, vis1_R, vis2_R, vis3_R;
 // инициализация количество обновлений (Update),
 // когда направление движения не к яблоку
 int repeat = 100;
public Transform target;
 // скорость перемещения - 6 единиц в секунду по умолчанию
 float speed = 6;
 // скорость вращения 3 градуса в секунду по умолчанию
 float rotationSpeed = 3;
 // коэффициент, определяющий направление поворота (krt=1 или krt=-1)
 float krt; 
 // локальная переменная для хранения ссылки на компонент CharacterController
 private CharacterController _controller;

public void Start()
 {
 tex = new Texture2D(camleft.targetTexture.width, camleft.targetTexture.height, TextureFormat.RGB24, false);

// получаем компонент CharacterController и 
 // записываем его в локальную переменную
 _controller = GetComponent<CharacterController>();
 // создаем хвост
 // current - текущая цель элемента хвоста, начинаем с головы
 Transform current = transform;
 for (int i = 0; i < 3; i++)
 {
 // создаем примитив куб и добавляем ему компонент Tail
 Tail tail = GameObject.CreatePrimitive(PrimitiveType.Cube).AddComponent<Tail>();
 // помещаем "хвост" за "хозяином"
 tail.transform.position = current.transform.position - current.transform.forward * 2;
 // ориентация хвоста как ориентация хозяина
 tail.transform.rotation = transform.rotation;
 // элемент хвоста должен следовать за хозяином, поэтому передаем ему ссылку на его
 tail.target = current.transform;
 // дистанция между элементами хвоста - 2 единицы
 tail.targetDistance = 2;
 // удаляем с хвоста коллайдер, так как он не нужен
 //Destroy(tail.collider);
 Destroy(tail.GetComponent<Collider>());
 // следующим хозяином будет новосозданный элемент хвоста
 current = tail.transform;
 }
 }
 
 public void Update()
 {
 // делаем RenderTexture левой камеры активным
 // при этом RenderTexture записывается в буфер
 RenderTexture.active = camleft.targetTexture;
 //Из буфера читаем RenderTexture.active в Texture2D
 tex.ReadPixels(new Rect(0, 0, tex.width, tex.height), 0, 0);
 // обновляем изменения 
 tex.Apply();
 //Достаем цвета пикселей из Texture2D
 col = tex.GetPixel(tex.width / 2, 8 * tex.height / 10);
 // Определяем цветовые признаки пикселей
 vis1_L = col.r / col.g;
 col = tex.GetPixel(9 * tex.width / 10, 8 * tex.height / 10);
 vis2_L = col.r / col.g;
 col = tex.GetPixel(2 * tex.width / 10, 8 * tex.height / 10);
 vis3_L = col.r / col.g;

// делаем RenderTexture правой камеры активным
 RenderTexture.active = camright.targetTexture;
 //Из буфера читаем RenderTexture.active в Texture2D
 tex.ReadPixels(new Rect(0, 0, tex.width, tex.height), 0, 0);
 // обновляем изменения 
 tex.Apply();
 //Достаем цвета пикселей из Texture2D
 col = tex.GetPixel(tex.width / 2, 8 * tex.height / 10);
 // Определяем цветовые признаки пикселей
 vis1_R = col.r / col.g;
 col = tex.GetPixel(9 * tex.width / 10, 8 * tex.height / 10);
 vis2_R = col.r / col.g;
 col = tex.GetPixel(2 * tex.width / 10, 8 * tex.height / 10);
 vis3_R = col.r / col.g;

 krt = 0; // коэффициент определяет направление движения (движение прямо)
if (vis1_L > 10 || vis1_R > 10 )
 { repeat = 0; krt = 1; } // поворот влево - в случае преграды справа и по центру
if (vis3_L > 10 || vis3_R > 10) krt = -1; // поворот вправо
 
// для 5 обновлений (Update) отключается движение к яблоку
 if (krt == 0 && repeat > 5) transform.LookAt(target);
 else
 {
 Debug.Log("SOS!!!"); // сообщение в консоль Unity
 transform.Rotate(0, rotationSpeed * Time.deltaTime * krt, 0);
 transform.LookAt(transform.forward);
 }
 _controller.Move(transform.forward * speed * Time.deltaTime /*vertical*/);
}
 
GameObject food;
 // В эту функцию будут передаваться все объекты, с которыми
 // CharacterController вступает в столкновения
 public void OnControllerColliderHit(ControllerColliderHit hit)
 {
 if (hit.collider.gameObject.name == "Food")
 {
 // прибавляем очки еды к общему числу очков
 Game.points += 10;

//Врезались в еду, "съедаем" ее и создаем новую в пределах поля. 
 //На самом деле перемещаем еду в Random положение
 food = GameObject.Find("Food");
 //Destroy(food);
 var pos = new Vector3(Random.Range(-40, 41), 0, Random.Range(-40, 41));
 food.transform.position = pos;
 }
 else
 {
 //врезались не в еду
 Application.LoadLevel("GameOver");
 }
 }
}

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

Очевидно, эти проблемы в той или иной степени алгоритмически решаемые.  Поэкспериментируйте с программой. Широкое поле для обучению основам программирования. При этом можно  наглядно наблюдать просчеты в реализации своих идей.  Дерзайте!!!

Полезные ссылки:

 

Автор: Николай Свирневский

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *