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

    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
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 30
      +6
      Ну вообще Фрустум — Frustum, а никак не FrustRoom, откуда вы это взяли. Frustum — это усеченная пирамида. en.wikipedia.org/wiki/Frustum
        +8
        Но идея неплохая. Комната для фрустраций. Наверное, даже лучше, чем комната кривых зеркал.
          +3
          Если меня будут спрашивать, то я в комнате злости.
          0
          Сорри, спасибо, исправил.
          +2
          Вообще хорошо все, однако статический конвейер в OpenGL безумно устарел. Ваш проект будет проще переписать, чем портировать на OpenGL ES 2.0
            0
            Во первых, статический конвеер не устарел. Для простых приложений, тем более для начинающих — самое то.
            Во вторых, OpenGL ES это для встраиваемых систем. Для PC всё же OpenGL 4.x.
              +1
              Статический конвеер устарел. Начинающим лучше сразу привыкать к связке VBO+ shaders, иначе потом будет тяжело переучиваться. Для простых объектов, конечно, лучше использовать glBegin/glEnd .glBegin/glEnd для low-poly объектов часто оказываются быстрее, чем буфера вершин.
                0
                Конечно устарел, современные видеокарты уже не поддерживают даже инструкции статического пайплайна. Там на каждое изменение стейта прослойка в API генерирует заново шейдеры.
              –1
              Статья интересная и точно будет полезна начинающим для понимания 3D.
              Но нельзя ли перед тем, как опубликовать, перечитать все внимательно? В статье куча ошибок. И я не говорю про орфографию. Когда на натыкаешься на такое
              кликаем по правой кнопкой мыши по панели инструментов

              понимаешь, в чем дело только после пятого прочтения.
              Это не претензия лично к автору, пол хабра этим страдает.
                +1
                Для новичков надо бы сначала теорию прочитать, например «Математические основы машинной графики», Роджерс Д., Адамс Дж. Книга старая, но математические основы описаны на порядок лучше чем в этой статье, куча примеров, сначала с 2д, потом все на 3д переводят.
                  +1
                  Я не мог тут объяснять математические основы, иначе статья была бы очень большой.
                    +1
                    На хабре уже имеется, кстати, отличная (и понятная) статья на тему проективной геометрии:
                    habrahabr.ru/post/126269/
                      0
                      Отличная статья
                    0
                    Сарказм?
                      0
                      Почему сарказм? Нет, я сам долго путался и понять не мог, потратил пару часов на вышеуказанную книгу и сразу всё понял, как заново мир открыл, сейчас без каких либо проблем делаю свой движок потихоньку, ради интереса. Просто достаточно было бы подобную книгу указать в статье, можно было бы и не писать.
                    0
                    Спасибо, исправил. Но когда пишешь такую статью, офигиваешь, устаёшь, и проверка ошибок затруднительна.
                    0
                    Udacity недавно на тему 3D выложило курс
                    www.udacity.com/course/cs291
                      0
                      Во время учебы в универе писал такой на C++, если не ошибаюсь, в качестве курсовой. Вот, если кому интересно: bitbucket.org/aiglikov/rubiks-cube (управление — левая/правая/средняя кнопка мыши).
                        0
                        Ну и для полноты картины мой старенький кубик на JS+Canvas без всяких библиотек:
                        habrahabr.ru/post/100576/
                          0
                          Кубик Рубика состоит из 26 кубиков нерубика(простых кубиков)
                            0
                            Кода было бы гораздо меньше при использовании VBO.
                              +1
                              www.arcsynthesis.org/gltut/ — лучше сразу начать читать вот это. Там про современный быстрый и куда более мощный pipeline. Фактически нет никакого смысла учить то, что уже сейчас устарело и никаких вообще знаний про то, как работают видеокарты не дает.
                                0
                                Ок. Изучу.
                                0
                                Плохой урок, который учит работе с древним фиксированным конвейером (который на современном железе эмулируется). Имхо уроки по depricated функционалу вообще не должны появляться.
                                  0
                                  сорри, я не специально, но что-то, когда искал материалы про opengl, в них не было написано, что это древность.
                                  Сорри.
                                    0
                                    Давайте я вам помогу, и расскажу как избегать такой проблемы на примере вашей ситуации.

                                    Вы захотели писать 3д приложения, но в этом как говорится ни в зуб ногой. Всегда есть 2 варианта, использовать голый API, или использовать библиотеки. Исходим из следующего:
                                    1. Мы хотим заниматься этим профессионально этой областью — наш выбор сначала API, и изучаем именно API, и только потом переходим на библиотеки. К этому времени у нас будет трезвый взгляд и понимание, какая именно библиотека лучше всего нам подходит. Для изучения API мы обязательно должны руководствоваться официальными спеками на апи. Для OpenGL это www.opengl.org/documentation/specs/ Но так же мы можем использовать примеры из интернетов, но обязательно по каждой API функции читать документацию в спеках. В интернете полно устаревших примеров.

                                    2. Мы хотим разово (но качественно) решить наши проблемы. Гуглим библиотеки. Среди библиотек выбираем только те, которые поддерживаются (в интернете полно устаревших библиотек). Ваш этот OpenTK последний раз релизился более двух лет назад: www.opentk.com/project/opentk а найтли билды: sourceforge.net/projects/opentk/files/opentk/nightly/ которые вываливались каждые 2-3 дня, уже не обновляются год. Допустим мы навыбирали актуальных поддерживаемых библиотек. Идем на специализированный форум:
                                    www.opengl.org/discussion_boards/forum.php
                                    www.gamedev.net/page/index.html
                                    gamedev.ru/
                                    И смотрим что народ говорит по этим библиотекам там. Если библиотека практически не упоминается, значит у неё пользователей мало, а это значит слабый фидбек о багах, и слабый саппорт, и скорее всего эта библиотека загнется. Исключение — очень молодые библиотеки.

                                    Ну и в конце концов можно спросить у людей на форуме какую библиотеку мне выбрать, А, B или C.

                                    p.s. Хотя для меня честно говоря странновато, что OpenTK 2010 года релиза использует фиксированный конвеер, и насколько я понимаю, эти методы не помечены как depricated (Obsolete)?
                                      0
                                      не помечены.
                                        0
                                        Насчет OpenTK, конечно грустно, что уже два года нет обновлений. Даже ветка прошлогодняя с говорящим названием есть.
                                        И это при том, что его используют продукты, которые довольно активно разрабатываются. Те же MonoGame, Xamarin.iOS, Xamarin.Android и т.д.
                                        Хоть на форуме и пишут, что он stable и production ready, но отсутствие активности настораживает. Хотя альтернатив у него я не видел. Может кто подскажет?
                                          0
                                          Если не упираться в OpenGL, то есть же XNA и SlimDX например. Есть еще всякие Ogre и Unity.

                                          p.s. Хотя сам я не дотнетчик, и не обладаю актуальной информацией по дотнетовским фреймворкам и движкам
                                            0
                                            спасибо за советы

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

                                  Самое читаемое