Pull to refresh

Пишем эмулятор Кубика Рубика

Reading time 20 min
Views 77K
OpenGL — платформонезависимая спецификация, описывающая программный интерфейс для создания компьютерных приложений, использующих двухмерную и трехмерную графику.
В этой статье я опишу, как можно создать эмулятор Кубика Рубика на OpenGL.

Кубик будет в 3D и его можно будет вращать мышкой, а переворачивать грани можно, кликая мышкой по стрелкам. При том стрелки появляются у ближайшей к зрителю грани.




Я буду описывать создание эмулятора Кубик Рубика на языке C#, для OpenGL буду использовать библиотеку OpenTK. Надо её скачать, и сделать в Visual Studio ссылку на эту библиотеку.

Экскурс в 3D


Теперь небольшое описание про 3D. Объекты в 3D у нас имеют 3 координаты x, y, z, а на экране монитора только две координаты. Очевидно, что на экране монитора надо показывать проекцию.



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

Поэтому надо ограничить то, что мы можем видеть:



Такая усеченная пирамида называется Фруструм (FrustRum), чтобы показать предмет на экране, мы определяем помещается ли он в Фруструме (те части, которые не помещаются мы отсекаем), потом мы проецируем на экран. Всё это за нас делает OpenGL.

Проба пера


Скачиваем библиотеку OpenTK. Запускаем файл, распаковываем библиотеку.

Создаём проект, добавляем ссылку на файл OpenTK.dll. А так как, мы будем использовать контрол GLControl, на котором будет отображаться Кубик Рубика, добавляем ещё и ссылку на OpenTK.GLControl.dll
OpenTK требует также ссылку на System.Drawing.dll, поэтому ещё раз входим в интерфейс добавления ссылки, и выбираем вкладочку .Net и ищем System.Drawing, и добавляем.

Добавление библиотеки OpenTK




Я буду использовать OpenGL, внутри обычной GUI-программы. Поэтому в режиме конструктора кликаем правой кнопкой мыши по панели инструментов, и выбираем “Выбрать элементы”, переходим на вкладку “Компоненты .NET Framework” и выбираем файл OpenTK.GLControl.dll. В списке появится новый элемент GLControl, ставим напротив него галочку. ОК. На панели инструментов появится новый элемент GLControl. Переносим его на форму и растягиваем на всю её форму.

Добавление контрола GLControl (канвас, холст)






Элемент GLControl имеет событие Load, оно срабатывает, когда этот элемент загрузился.
(Щелкнем по нему, чтобы заполнить тело обработчика, появится метод glControl1_Load)
Создатели OpenTK не рекомендуют начинать работать с GLControl, пока он не загрузился, поэтому нужно заводить переменную, которая будет хранить значение, загрузился ли GLControl:

Код
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

using OpenTK;
using OpenTK.Graphics.OpenGL;

namespace habr
{
    public partial class Form1 : Form
    {
        bool loaded = false;//<--------------------------------------
        public Form1()
        {
            InitializeComponent();
        }

        private void glControl1_Load(object sender, EventArgs e)
        {
            loaded = true;//<--------------------------------------
        }

        private void glControl1_Paint(object sender, PaintEventArgs e)
        {
            if (!loaded)//<--------------------------------------
            return;//<--------------------------------------
        }
    }
}



glControl1_Load — метод, который обрабатывает событие Load
glControl1_Paint — метод, который обрабатывает событие Paint, срабатывает, например, когда мы скрываем, а потом снова открываем окно или, например, изменяем размеры окна.

Собственно нарисуем кубик.

Код, рисующий маленький кубик
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

using OpenTK;
using OpenTK.Graphics.OpenGL;

namespace habr
{
    public partial class Form1 : Form
    {
        bool loaded = false;
        public Form1()
        {
            InitializeComponent();
        }

        private void glControl1_Load(object sender, EventArgs e)
        {
            loaded = true;
            GL.ClearColor(Color.SkyBlue);
            GL.Enable(EnableCap.DepthTest);
            
            Matrix4 p = Matrix4.CreatePerspectiveFieldOfView((float)(80 * Math.PI / 180), 1, 20, 500);
            GL.MatrixMode(MatrixMode.Projection);
            GL.LoadMatrix(ref p);

            Matrix4 modelview = Matrix4.LookAt(70, 70, 70, 0, 0, 0, 0, 1, 0);
            GL.MatrixMode(MatrixMode.Modelview);
            GL.LoadMatrix(ref modelview);
        }

        private void glControl1_Paint(object sender, PaintEventArgs e)
        {
            if (!loaded)
                return;

            GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
            
     float width = 20;            
            /*задняя*/
            GL.Color3(Color.Red);
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(width, 0, 0);
            GL.Vertex3(width, width, 0);
            GL.Vertex3(0, width, 0);
            GL.End();

            /*левая*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(0, width, width);
            GL.Vertex3(0, width, 0);
            GL.End();

            /*нижняя*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, 0, 0);
            GL.End();

            /*верхняя*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, width, 0);
            GL.Vertex3(0, width, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(width, width, 0);
            GL.End();

            /*передняя*/            
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(0, width, width);
            GL.End();

            /*правая*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(width, 0, 0);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(width, width, 0);
            GL.End();

			/*ребра*/
            GL.Color3(Color.Black);
            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, width, 0);
            GL.Vertex3(width, width, 0);
            GL.Vertex3(width, 0, 0);
            GL.End();

            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(width, 0, 0);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(width, width, 0);
            GL.End();

            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(0, width, width);
            GL.End();

            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(0, width, width);
            GL.Vertex3(0, width, 0);
            GL.End();

            glControl1.SwapBuffers();
        }        
    }
}



using OpenTK; — нужен для класса Matrix4 (матрица 4x4)
using OpenTK.Graphics.OpenGL; — нужен для получения доступа к объекту GL.

GL — объект, через который собственно вызывать команды OpenGL.
GL.ClearColor(Color.SkyBlue); — заливает голубым цветом
GL.Enable(EnableCap.DepthTest); — эта строчка нужна, чтобы дальние элементы перекрывались ближними.

Matrix4 p = Matrix4.CreatePerspectiveFieldOfView((float)(80 * Math.PI / 180), 1, 20, 500);
GL.MatrixMode(MatrixMode.Projection);
GL.LoadMatrix(ref p);


Здесь мы задаём матрицу, которая отвечает за Фруструм:
1) угол обзора 80 градусов
2) отношение длины к высоте — 1
3) расстояние до первой грани — 20
4) расстояние до дальней грани — 500

Переходим в режим проекции, и задаем эту матрицу. О режимах будет сказано чуть позже.

GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

Инициализируем ColorBufferBit и DepthBuffer

ColorBuffer. Буфер Цвета. С каждым пикселем на экране связано значение цвета, которое записывается в буфере цвета. Вызов GL.Clear(ClearBufferMask.ColorBufferBit) зальет в нашем случае окно цветом SkyBlue (смотри выше).

DepthBuffer. Он же Z-Buffer. Буфер глубины. Дело в том, что две точки в 3D пространстве могут проецироваться на одну точку на экране. Нужно чтобы, ближняя точка перекрывала дальнюю. Для этого нужно вычислять “глубину” точки (величины обратнопорциональной расстоянию от камеры до точки) и записывать её значение в буффер (пискель такой-то, глубина такая-то),
если же очередная точка проецируется на тот же пиксель, то надо сравнивать “глубину” новой точки с записанной Depth-буффере. Если новая точка находится “менее глубоко” (более ближе к камере), то её проекция должна перекрыть существующую, иначе оставляем всё как есть.
В начале отрисовки кубика мы должны очистить Depth-Buffer.

Matrix4 modelview = Matrix4.LookAt(70, 70, 70, 0, 0, 0, 0, 1, 0);
GL.MatrixMode(MatrixMode.Modelview);
GL.LoadMatrix(ref modelview);


Здесь мы задаем нашу камеру в точке (30, 70, 80), направление взгляда в центр системы координта (0, 0, 0). Ориентация такая, что ось OY направлена вверх.

Если мы сделаем
Matrix4 modelview = Matrix4.LookAt(30, 70, 80, 0, 0, 0, 1, 1, 0);


То мы будем смотреть на кубик под углом, как если бы наклонили голову на 45 градусов влево.

Далее собственно рисуются сам кубик: сначала грани красным цветом, потом черным — ребра

Потом вызывается команда
glControl1.SwapBuffers();


Дело в том, что по умолчанию OpenGL в OpenTK double-buffer: каждый буффер (ColorBuffer, DepthBuffer и другие, которые я не упомянул) дублируется. Когда мы рисуем изображение, мы используем одни буферы. А в это время на экране отображается изображение, которое получено из других буферов.
Командой glControl1.SwapBuffers(); мы выводим на экран изображение, используя буферы, в которых мы его рисовали.
Кстати, если очисть буфер цвета только в первый раз

bool b = true;
private void glControl1_Paint(object sender, PaintEventArgs e)
{
	if (!loaded)
		return;

	GL.Clear(ClearBufferMask.DepthBufferBit);
	if (b)
	{
		GL.Clear(ClearBufferMask.ColorBufferBit);
	}
	b = false;
…


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

Теперь о режимах


Объекты задаются в 3-х мерных координатах. Эти координаты называются объектными. Каждый объект может быть определен в своих объектных координатах. Чтобы построить мир из разных 3d объектов, которые стоят относительно друг друга в разных положениях,
нужно объектные координаты каждого объекта умножить на соответствующую модельную матрицу (model Matrix). Тогда мы получим новые координаты каждого объекта в новом общем мировом пространстве.

В то же время мы можем смотреть на мир объектов с разных сторон, мы можем перевернуть камеру, мы можем приближаться к объекту и удаляться от него. Умножая координаты объектов (координаты в мировом пространстве) на соответствующие матрицы видового преобразования (view Matrix), мы получаем видовые коордианты каждого объекта.

В OpenGL матрица модельного преобразования (model Matrix) совмещена с матрицей видового преобразования (view Matrix) в одну (modelView Matrix). (Ведь мы можем отдалить объект двумя способами: изменить его мировые координаты (отдалить сам объект), либо отдалить от него камеру (получить новые видовые координаты)).

Потом координаты умножаются на матрицу проекции (projection Matrix), которая либо задаёт Фруструм (перспективное проецирование):


либо задаёт ортогональное проецирование:


Умножая видовые координаты на матрицу проекции, мы получаем усеченные координаты (clip coordinates). Деля каждую координату (x, y, z) на 4 величину ω, мы получаем нормализованые координаты устройства (Normalize Device Coordinates, NDC) каждая из которых от -1 до 1, при том ось Z развернута уже от нас (то есть Фруструм по сути превращается в куб и разворачивается от нас на 180 градусов),
далее координаты сдвигом и масштабированием преобразуются в оконные координаты (window coordinates), которые уже наконец и участвую в построении 2D-изображения на экране.

Чтобы перейти в режим управления матрицей проецирования мы должны вызвать функцию GL.MatrixMode с параметром MatrixMode.Projection:
GL.MatrixMode(MatrixMode.Projection);

А чтобы перейти в режим управления матрицей модельно-видового преобразования мы должны вызвать функцию GL.MatrixMode с параметром MatrixMode.Modelvew:
GL.MatrixMode(MatrixMode.ModelView);

Добавьте в glControl1_Paint код, рисующий оси OX, OY, OZ:

GL.Color3(Color.Black);
GL.Begin(BeginMode.Lines);
GL.Vertex3(0, 0, 0);
GL.Vertex3(50, 0, 0);
GL.Vertex3(0, 0, 0);
GL.Vertex3(0, 50, 0);
GL.Vertex3(0, 0, 0);
GL.Vertex3(0, 0, 50);
GL.End();


Также в дизайнере форм, надо добавить обработчик для события KeyDown, появится функция glControl1_KeyDown. Заполните её следующим кодом:

private void glControl1_KeyDown(object sender, KeyEventArgs e)
{
	if (!loaded) return;

	if (e.KeyCode == Keys.A)
	{
		GL.MatrixMode(MatrixMode.Projection);
		GL.Rotate(30, 0, 0, 1);
	}
	if (e.KeyCode == Keys.B)
	{
		GL.MatrixMode(MatrixMode.Modelview);
		GL.Rotate(30, 0, 0, 1);
	}

	glControl1.Invalidate();
}


То есть при нажатии клавиши A на клавиатуре мы переходим в режим проекции и делаем поворот вокруг оси OZ на 30 градусов против часовой стрелки,
а при нажатии клавиши B тоже осуществляется поворот вокруг оси OZ, но уже в режиме модельно-видового преобразования.

Полный код привожу здесь:
маленький кубик, который вращается по нажатиям A и B
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

using OpenTK;
using OpenTK.Graphics.OpenGL;

namespace habr
{
    public partial class Form1 : Form
    {
        float width = 20;
        bool loaded = false;        
        public Form1()
        {
            InitializeComponent();
        }

        private void Form1_Load(object sender, EventArgs e)
        {

        }

        private void glControl1_Load(object sender, EventArgs e)
        {
            loaded = true;
            GL.ClearColor(Color.SkyBlue);
            GL.Enable(EnableCap.DepthTest);
            
            Matrix4 p = Matrix4.CreatePerspectiveFieldOfView((float)(80 * Math.PI / 180), 1, 20, 500);
            GL.MatrixMode(MatrixMode.Projection);
            GL.LoadMatrix(ref p);

            Matrix4 modelview = Matrix4.LookAt(70, 70, 70, 0, 0, 0, 0, 1, 0);
            GL.MatrixMode(MatrixMode.Modelview);
            GL.LoadMatrix(ref modelview);          
        }

        private void glControl1_KeyDown(object sender, KeyEventArgs e)
        {
            if (!loaded) return;

            if (e.KeyCode == Keys.A)
            {
                GL.MatrixMode(MatrixMode.Projection);
                GL.Rotate(30, 0, 0, 1);
            }
            if (e.KeyCode == Keys.B)
            {
                GL.MatrixMode(MatrixMode.Modelview);
                GL.Rotate(30, 0, 0, 1);
            }

            glControl1.Invalidate();
        }

        private void glControl1_Paint(object sender, PaintEventArgs e)
        {
            if (!loaded)
                return;

            GL.Clear(ClearBufferMask.DepthBufferBit | ClearBufferMask.ColorBufferBit);            
            
            /*задняя*/
            GL.Color3(Color.Red);
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(width, 0, 0);
            GL.Vertex3(width, width, 0);
            GL.Vertex3(0, width, 0);
            GL.End();

            /*левая*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(0, width, width);
            GL.Vertex3(0, width, 0);
            GL.End();

            /*нижняя*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, 0, 0);
            GL.End();

            /*верхняя*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, width, 0);
            GL.Vertex3(0, width, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(width, width, 0);
            GL.End();

            /*передняя*/            
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(0, width, width);
            GL.End();

            /*правая*/
            GL.Begin(BeginMode.Polygon);
            GL.Vertex3(width, 0, 0);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(width, width, 0);
            GL.End();

            GL.Color3(Color.Black);
            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, width, 0);
            GL.Vertex3(width, width, 0);
            GL.Vertex3(width, 0, 0);
            GL.End();

            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(width, 0, 0);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(width, width, 0);
            GL.End();

            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(width, 0, width);
            GL.Vertex3(width, width, width);
            GL.Vertex3(0, width, width);
            GL.End();

            GL.Begin(BeginMode.LineLoop);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 0, width);
            GL.Vertex3(0, width, width);
            GL.Vertex3(0, width, 0);
            GL.End();

            GL.Color3(Color.Black);
            GL.Begin(BeginMode.Lines);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(50, 0, 0);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 50, 0);
            GL.Vertex3(0, 0, 0);
            GL.Vertex3(0, 0, 50);
            GL.End();

            glControl1.SwapBuffers();
        }        
    }
}


Если мы будем нажимать букву A на клавиатуре, то у нас будет вращаться 2D-изображение на экране:


Таким образом в перспективных координатах ось OZ является и осью Фруструма.

В случае нажатия B на клавиатуре, то у нас будет вращаться система координат вокруг оси OZ:


Код
GL.MatrixMode(MatrixMode.Projection);
GL.Rotate(30, 0, 0, 1);


С тем же успехом можно было заменить таким:

Matrix4d projection_matrix;//Матрица 4x4, элементы типа double
GL.GetDouble(GetPName.ProjectionMatrix, out projection_matrix);//Загружаем матрицу проецирования в projection_matrix

//Подготавливаем матрицу поворота вокруг оси OZ                
double cos = Math.Cos(-30 * Math.PI / 180);
double sin = Math.Sin(-30 * Math.PI / 180);

Matrix4d rotating_matrix = new Matrix4d(
cos, -sin, 0, 0,
sin, cos,  0, 0,
0,     0,  1, 0,
0,     0,  0, 1
);

projection_matrix *= rotating_matrix;//умножаем матрицу проецирования на матрицу поворота
GL.MatrixMode(MatrixMode.Projection);//переходим в режим проецирования
GL.LoadMatrix(ref projection_matrix);//устанавливаем новую матрицу проецирования


Тот же код над матрицей ModelView даст тот же результат.

Собственно перейдём к описанию программы эмулятора кубика-рубика, которую вы можете скачать здесь: http://труъкодинг.рф/files/opengl.zip

Наконец-то


Всё описывать не буду, так как будет очень много текста. Опишу ключевые моменты.

Ключевые структуры данных

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

Для отслеживания позиций кубиков, я применил массив positions:
int[] positions;

Его ключи — это номера кубиков, а значения номера позиций.

Кстати, позиции я обозначил так:


2) Когда вращаешь грань КР, соответствующие маленькие кубики меняют не только местоположение, но поворачиваются другими сторонами. Когда мы повернули грань на один оборот (90 градусов),
то новое состояние кубика можно получить двумя путями:
1) повернуть соответствующие кубики вокруг определенной оси на 90 градусов (что и делалалось при повороте)
2) либо переставить кубики, на новые места, и повернуть каждый кубик вокруг своей оси на 90 градусов.

Следующий класс служет для описания кубика в пространстве.

public class angleXYZ
    {
        public angleXYZ()
        {
            this.X = 0;
            this.Y = 0;
            this.Z = 0;
        }
        public int X { get; set; }
        public int Y { get; set; }
        public int Z { get; set; }
    }


поля X, Y, Z — это углы относительно осей OX, OY, OZ
когда мы вращаем какую-либо грань, у соответствующих кубиков меняется соответствующий угол.
После заканчивания вращения, я обнуляю эти углы, перемещаю кубики на новые позиции (то есть соответствующим образом меняю массив position), и вращаю кубики вокруг своей оси (покажу, что под этим имею в виду). Пользователь видит только само вращение.

Объект класса angleXYZ есть у каждого кубика и хранится в коллекции angles:
List<angleXYZ> angles = new List<angleXYZ>();


3) Каждый кубик содержит 8 угловых точек. Зная эти точки, нарисовать кубик не проблема.
Координаты точек хранятся в 3-х мерном массиве edges. Чтобы перенос и поворот координат проходили с помощью только операций умножения (а не сложение и умножение), я использую матрицы 1x4 для координат и матрицы 4x4 для матриц переноса и умножения.
Использование матриц 4x4 позволяет соединить операцией умножения воедино и матрицу переноса, и поворота. Тем самым за одну операцию умножения можно сделать две вещи: и перенос, и умножение.

float[][][] edges;
…
//заполняем
edges = new float[n][][];//n - количество кубиков (27, если 3x3x3)
…
for (int i = 0; i < n; i++)
{
	float[][] vectors = new float[8][] {
		//w-длина ребра кубика
		new float[4] { 0, 0, 0, 1 },
		new float[4] { 0, 0, w, 1 },
		new float[4] { 0, w, 0, 1 },
		new float[4] { 0, w, w, 1 },
		new float[4] { w, 0, 0, 1 },
		new float[4] { w, 0, w, 1 },
		new float[4] { w, w, 0, 1 },
		new float[4] { w, w, w, 1 },
	};
	edges[i] = vectors;
	…
	//смещаем кубик
	List<int> data = getOffsets(i);
	int offset_x = data[0];
	int offset_z = data[1];
	int offset_y = data[2];

	for (int j = 0; j < edges[i].Length; j++)
	{
		//w - длина кубика, spacing - расстояние между кубиками 
		//(кубики могут стоять не плотно)
		edges[i][j][0] += offset_x * (w + spacing);
		edges[i][j][1] += offset_y * (w + spacing);
		edges[i][j][2] += offset_z * (w + spacing);
	}
}


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

4) Есть ещё словарь intersect_planes.
Ключи словаря — это оси (объект перечисления Axis (public enum Axis { X, Y, Z };)),
а значения — это грани на соответствующей оси, объекты моего класса Plane (плоскость).

public enum Axis { X, Y, Z };
Dictionary<Axis, Plane[]> intersect_planes = new Dictionary<Axis,Plane[]>();


Класс Plane нужен для хранения координат точек угловых точек каждой грани.

side = N * w + (N - 1) * spacing;// - длина грани
//координаты угловых точек
Vector3 p1 = new Vector3(0, side, side);//<----
Vector3 p2 = new Vector3(side, 0, side);
Vector3 p3 = new Vector3(side, side, side);//<----
Vector3 p4 = new Vector3(side, 0, 0);
Vector3 p5 = new Vector3(0, 0, 0);
Vector3 p6 = new Vector3(0, side, 0);//<----

Vector3 p7 = new Vector3(0, 0, side);
Vector3 p8 = new Vector3(side, side, 0);            

intersect_planes[Axis.X] = new Plane[2] {
new Plane(p2, p3, p8),//кубики 2, 5, 8, 11, 14, 17, 23, 20, 26 Ось X
new Plane(p1, p7, p5)//кубики 0, 3, 6, 9, 12, 15, 18, 21, 24 Ось X
};
intersect_planes[Axis.Y] = ...
….


объект класса Plane просто хранит координаты 3-х точек, а конструктор этого класса проверяет, чтобы они были разные. Но можно было и не заводить отдельный класс, а просто обойтись двумерным массивом:

intersect_planes[Axis.X] = new Vector3[2][] {
	new Vector3[]{p2, p3, p8},//кубики 2, 5, 8, 11, 14, 17, 23, 20, 26 Ось X
	new Vector3[]{p1, p7, p5},//кубики 0, 3, 6, 9, 12, 15, 18, 21, 24 Ось X
};


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

Этот словарь нужен для того, чтобы определить по плоскости какой грани мы щелкнули мышкой, соответственно какую часть Кубика Рубика надо вращать. Об определении плоскости, по стрелкам которой щелкнули мышкой, будет написано позже.

5) Важный объект моего vp класса ViewPoint.
ViewPoint vp = new ViewPoint();


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

Класс ViewPoint нужен для получения ближайшей оси к точке зрения (метод getNearestAxis). Это нужно, чтобы определить, на какой грани показывать стрелочки, то есть какие части вращать, при щелчке мыши.

Точка обзора вращается вокруг Кубика Рубика по сфере, поэтому удобно оперировать углом относительно оси OX (угол α) и углом относительно ocи OY (угол β):


У объекта vp открыты свойства-сеттеры angle_view_alpha и angle_view_beta, через них меняются угол α и угол β, а в теле сеттеров по этим углам рассчитываются координаты камеры (точки обзора).
Также у этого класса есть свойства-геттеры, по которым можно определить не верх ногами находится ли камера, с какой стороны определенной оси мы смотрим на кубик (к примеру со стороны положительных значений X, или со стороны отрицательных значений Z).
Это нужно для того, чтобы правильно определять в какие стороны крутить грани Кубика Рубика. Сам Кубик Рубика расположен так, чтобы его центр был в центре начала координат.

Перейдём к коду


Я буду описывать только ключевые моменты, иначе будет очень долго. Я и так себя уже Львом Николаевичем чувствую.

Метод Render

Сам Кубик Рубика я хочу, нарисовать так, чтобы его центр совпадал с центром начала координат, поэтому перейдя в режим матрицы ModelView, я делаю перенос системы координат на половину длины Кубика Рубика по всем осям:

double offset0 = this.w * N + (N - 1) * spacing;//длина Кубика Рубика w - длина маленькоо кубика, N - размерность КР (3), spacing - расстояние между маленькими кубиками.
double offset = offset0 / 2;
GL.Translate(
	-offset,
	-offset,
	-offset
);


Потом по количеству кубиков (27 раз) вызывается функция cube, которая рисует маленькие кубики за исключением того, что находится в центре КР, потому что его не будет видно никогда.

Функция cube


Я сначала сохраняю, текущую матрицу ModelView в стэке, который предоставляет OpenGL:
GL.PushMatrix();

а в конце восстанавливаю эту матрицу из стэка:
GL.PopMatrix();


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

Для анимации прокрутки грани, я делаю поворот вокруг вокруг какой-либо из осей.
float offset = (w * N + (N - 1) * spacing) / 2;//середина всего Кубика Рубика
GL.Translate(
	offset,
	offset,
	offset
);

GL.Rotate(angle.X, Vector3.UnitX);
GL.Rotate(angle.Y, Vector3.UnitY);
GL.Rotate(angle.Z, Vector3.UnitZ);

GL.Translate(
	-offset,
	-offset,
	-offset
);


Код написан так, что только один из углов angle.X, angle.Y, angle.Z в один момент може быть ненулевым, так что здесь выполняется поворот только вокруг одной оси, либо не выполняется вообще.

Но, памятуя о том, что система координат смещена, надо сначала вернуть её на место, сделать поворот, и снова сделать обратный перенос, что у меня и сделано:



Далее используется массив edges для рисования кубика, цвета граней кубика определяются из номера кубика, который передаётся функции cube.

Стрелки


Центр системы координта с помощью GL.Translate возвращается в первоначальное место.
Потом с помощью объекта vp класса ViewPoint определяется наиближайшая к камере ось системы координат и с какой стороны. Делается соответствующий поворот с помощью GL.Rotate и методом GL.Translate делается такой перенос, чтобы стрелки рисовались у ближайшей грани.

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

Для определения 2-х точек такой прямой находятся нормализованные координаты устройства (NDC, речь о них выше), которые надо умножить на инвертированную матрицу, полученную в помощью умножения матриц ModelView и проекции.
В результате мы получим координаты двух точек, лежащих на ближайшей и дальней плоскостях Фруструма.

System.Windows.Forms.MouseEventArgs me = (e as System.Windows.Forms.MouseEventArgs);                        
double y = me.Y;
double x = me.X;
int w = glControl1.Width;
int h = glControl1.Height;

float xpos = (float)(2 * (x / w) - 1);
float ypos = (float)(2 * (1 - y / h) - 1);
Vector4 startRay = new Vector4(xpos, ypos, 1, 1);
Vector4 endRay = new Vector4(xpos, ypos, -1, 1);
            
// Reverse Project
Matrix4 modelview = new Matrix4();
Matrix4 projection = new Matrix4();

GL.GetFloat(GetPName.ModelviewMatrix, out modelview);
GL.GetFloat(GetPName.ProjectionMatrix, out projection);

Matrix4 trans = modelview * projection;
trans.Invert();
startRay = Vector4.Transform(startRay, trans);
endRay = Vector4.Transform(endRay, trans);
sr = startRay.Xyz / startRay.W;
er = endRay.Xyz / endRay.W;


Снова возвращаемся в метод Render


Зная ближаюшую грань и две точки прямой, мы находим точку пересечения прямой и плоскости, где лежит ближайшая к нам грань Кубика Рубика (то есть в дело вступает словарь intersect_planes, содержащий точки плоскости).

Функцию определения определения точки пересечения я нагуглил. Далее в коде определяется в грань мы кликнули или в стороне от неё (по стрелке), и с боку от грани или сверху снизу. Это всё легко потому, что сам кубик не двигается, а его стороны паралельны плоскостям XOY, XOZ, YOZ (то есть всё определяется сравнением меньше/больше координат точки пересечения и координат 4-х угловых точек).
Ну а потом запускаем процесс вращения грани.

Вращение грани

Для анимации вращения грани я создал класс EasingTimer, наследуемый от System.Timers.Timer, с помощью шаблона проектирования Singleton, можно создать только один элемент этого класса. Это я сделал для того, чтобы случайно две грани одновременно не вращались. Также есть переменная run, которая определяет запущен ли процесс вращения, и новый процесс вращения не наступит, пока не закончится предыдущий.

Свойство duration объекта класса EasingTimer задаёт в миллисекундах длительность вращения.
Само вращение происходит так:

  1. сохраняется начальное время
  2. стартуется Таймер на один раз на 100 миллисекунд.
  3. когда срабатывает таймер, вызывается функция поворота грани rotatePart, которая изменяет элементы списка angles соответствующих кубиков. Напомню, что каждый элемент этого списка отвечает за поворот определенного кубика в пространстве.
    public class angleXYZ
        {
            public angleXYZ()
            {
                this.X = 0;
                this.Y = 0;
                this.Z = 0;
            }
            public int X { get; set; }
            public int Y { get; set; }
            public int Z { get; set; }
        }
    

    функция поворота, изменяет только один угол либо X, либо Y, либо Z,
    таймер необязательно сработает через 100 миллисекунд, поэтому очередная дельта угла поворота определяется исходя из миллисекунд, прошедших от даты, которую мы сохранили на первом шаге.
    Таймер перезапускается, и вызывается метод glControl1.Invalidate();, который вызвыает метод Render, который в свою очередь вызывает метод cube для отрисовки каждого маленького кубика, а так как у некоторых кубиков соответствующие элементы списка angles изменены, то мы увидим вращение грани. Грань будет продолжать вращаться, пока срабатывает таймер.
  4. Когда функция поворота грани rotatePart, определяет что прошло duration миллисекунд.
    она сбрасывает углы поворота у кубиков, которые поворачивала (если в этот момент вызывать метод Render, то Кубик Рубика будут в предыдущем состоянии, как будто ничего не вращали).
    Далее меняется массив positions (который указывает, где какой кубик находится, смотри выше), чтобы отображать новое состояние Кубика Рубика. Но этого не достаточно — после вращения грани, соответсвующие маленькие кубики не только поменяли своё местоположение, но и словно сделали поворот вокруг своей оси.
    Другими словами поворот маленького кубика при вращении грани, эквивалентен изменению положения кубика и его вращение вокруг своей оси:



Поэтому элементы массив edges, содержащим координаты 8 точек маленьких кубиков, которые вращались умножаются на соответствующую матрицу поворота.
Далее опять через вызов glControl1.Invalidate(); вызывается метод Render, который будет вызывать метод cube, которая покажет кубики на новых местах и соответствующе повернутых.

Теперь разъясню почему был придуман массив positions, указывающий на какой позиции какой кубик стоит (27 кубиков, 27 позиций). Дело в том, что в процессе вращения граней Кубика Рубика, маленькие кубики будут всё время менять своё местоположение, и надо уже оперировать не командами вида “повернуть кубик номер 3 на 90 градусов вокруг оси X”, а оперерировать командами вида “повернуть кубик, стоящий на позиции номер 3, на 90 градусов вокруг оси X”.

Теперь разъясню, почему я сбрасываю в нули элементы массива angles, отвечающих за поворот кубика в пространстве. Дело в том, что последовательность поворотов имеет значение. Поворот вокруг оси X, потом поворот вокруг оси Y не тоже самое, что поворот вокруг оси Y, а потом поворот вокруг оси X.

Если никогда не обнулять углы поворота кубиков, то следующий код из метода cube, реализующий анимацию вращения

будет нормально работать только первые три вращения, да и то, если будут в таком порядке: вокруг X, вокруг Y, вокруг Z.
GL.Rotate(angle.X, Vector3.UnitX);
GL.Rotate(angle.Y, Vector3.UnitY);
GL.Rotate(angle.Z, Vector3.UnitZ);

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

Вращение всего Кубика Рубика

Вращение всего Кубика Рубика происходит только с помощью изменения точки обзора. Это реализовано в обработчиках MouseDown, MouseMove, MouseUp. Просто высчитывается, на сколько переместилась мышка по окну программы, перемещения преобразуются в соответствующие углы поворота, по вертикали и горизонтали.

Изменяются соответствующие свойства объекта vp класса ViewPoint, отвечающие за углы от оси X, и Y (смотри картинку выше), а внутри методов класса ViewPoint определяется новые координаты камеры и перевернута ли она, затем создаётся новая матрица ModelView:

G_modelview = Matrix4.LookAt(vp.viewX, vp.viewY, vp.viewZ, 0, 0, 0, 0, vp.orintation_y, 0);


первые 3 элемента задают координаты камеры,
вторые 3 элемента задают, что камера будет направлена на центр системы координат.
последние 3 элемента задают, как повернута камера
здесь задается, что ось Y будет направлена либо вверх, если vp.orintation_y == 1, либо вниз если vp.orintation_y == -1

За сим всё.
Всё остальное, разбирать как то смысла не вижу, да ещё и долго будет. Спасибо за внимание. Проект вы можете скачать здесь: http://xn--c1abmgrdmpk4e.xn--p1ai/files/opengl.zip
Tags:
Hubs:
+41
Comments 30
Comments Comments 30

Articles