Tags: Unity snake камера stereo vision
Постановка задачи
Движение змейки к цели по прямой
Установка камер на глазах змейки
Поток изображений с камер
Формализация движения в обход преград
Считывание и анализ точек изображений
Определение траектории движения
Полезные ссылки
Постановка задачи
В статье Игра “Snake” на Unity3D было рассмотрено, как создать игру «Змейка».
Змейка двигается вперед и меняет направление под управлением клавиш влево/вправо. Задача игрока — обойти препятствия и направить змейку к яблоку.
Ниже исследуются возможности создания на основе этой игры симулятора интеллекта змейки со стереозрением.
В чем конкретно заключается задача? При запуске приложения змейка самостоятельно (без управления игроком) должна двигаться к яблоку в обход всех преград.
Для реализации задачи необходимо:
- Направить змейку к цели — по прямой.
- Установить камеры в глазах змейки.
- Обработать изображения от камер.
- Менять траекторию движения для обхода преград.
Движение змейки к цели по прямой
За движение змейки отвечает скрипт 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"); } } }
Следует заметить, что результаты работы алгоритма далеко не безупречны. Это всего лишь первое приближение, как сделать змейку зрячей и умной. Алгоритм работает корректно только для простых случаев обхода единичного кубика, расположенного на пути движения змейки к яблоку. В случае скопления кубиков перед змейкой и, в связи с этим, резких изменений направления движения, змейка, обходя одну преграду, может столкнуться с другой.
Очевидно, эти проблемы в той или иной степени алгоритмически решаемые. Поэкспериментируйте с программой. Широкое поле для обучению основам программирования. При этом можно наглядно наблюдать просчеты в реализации своих идей. Дерзайте!!!
Полезные ссылки:
- Simulation for machine vision projects — Kapernikov
- Синхронное определение местоположения и составление 2D-карты по стерео изображению в режиме реального времени
- Reinforcement Deep Q Learning for playing a game in Unity
- Как я учил змейку играть в себя с помощью Q-Network
- Создаём мозг для «змейки». Часть1
- Создаём мозг для «змейки». Часть 2.
- Создаём мозг для «змейки». Часть 3. Идиократия
- Руководство Unity
- Графика в Unity 3D
- Справочник по камерам
- Руководство Unity. Камера
- Смена камеры
- Camera.pixelRect
- GetPixels of RenderTexture
- Unity3D: как узнать степень освещения точки сцены?
- How to capture video in C# without sacrificing performance.
- Виртуальный квадрокоптер на Unity + OpenCV (Часть 1)
- Виртуальный квадрокоптер на Unity + OpenCV (Часть 2)
- Виртуальный квадрокоптер на Unity + OpenCV (Часть 3)
- The human eye’s understanding of space for Augmented Reality
- Neural networks — training one to play a snake game.
- Designing AI: Solving Snake with Evolution
- LearnSnake: Teaching an AI to play Snake (Q-Learning)
- How to teach AI to play Games: Deep Reinforcement Learning
- Создание искусственного интеллекта для игр — от проектирования до оптимизации
- Искусственный Интеллект. Часть первая
- Creating an A.I. (with Unity)
- Teaching AI to play Flappy Bird with Unity
Автор: Николай Свирневский