Быстрый старт

В данном руководстве мы создадим небольшое приложение, которое позволяет перемещать, вращать, скрывать и показывать модель логотипа нашей компании. Будут рассмотрены базовые способы взаимодействия с объектами студии, обработка событий, а также работа с объектами в трехмерном пространстве. Для усвоения данного руководства желательны базовые знания основ программирования(переменные, функции, условия), базовое знание синтаксиса языка LUA, а также представление о работе с объектами в трехмерном пространстве(вектора, матрицы). Итак, начнем!

Шаг 1

Скачайте проект руководства с нашего гитхаба. Необходимый проект находится в папке rotate_model. Запустите студию и откройте проект. Вы должны увидеть следующее:

../_images/quickstart_2.png

Шаг 2

Создадим в любом редакторе файл main.lua

Примечание

Файл должен обязательно называться main.lua, т.к. именно он является входной точкой для нашей программы

Созданный файл мы добавляем в проект во вкладке ресурсы. Теперь можно написать наш первый код на языке LUA!

Шаг 3

Для начала сделаем, чтобы наша модель исчезала по нажатию клавиши H(в английской раскладке) на клавиатуре. Сделать это не так сложно. Но что нам для этого нужно? Во-первых, мы должны как-то получить саму модель в коде. Во-вторых нам надо обработать событие - нажатие клавиши на клавиатуре.

Для получения доступа к модели испозуется глобальная переменная reactorController, позволяющая получить любой реактор проекта. Реактор это любой объект взаимодействия в проекте, будь то модель, метка, текст и т.д. Чтобы получить реактор модели мы вызываем метод getReactorByName, который, исходя из названия, получает любой реактор проекта по его имени. В нашем случае название модели - logo, соотвественно пишем следующую строку кода:

local logoModel = reactorController:getReactorByName("logo")

Что произошло в коде? Мы объявили локальную переменную logoModel, и присвоили ей значение - реактор с названием «logo».

Теперь нам нужно как-то обработать нажатие клавиши на клавиатуре. EV Toolbox для работы с объектами в сцене использует библиотеку OpenSceneGraph, которая имеет свой встроенный обработчик событий - любое событие, будь то нажатие клавиши или мыши, произошедшее в окне будет им обработано. Итак, создадим обработчик событий:

local eventsHandler = osgGA.GUIEventHandler(function(ea, aa)
        if ea:getEventType() == osgGA.GUIEventAdapter.KEYDOWN then              -- проверяем, нажата ли клавиша
                if ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_H) then      -- проверяем, нажата ли клавиша H
                        logoModel:hide()                -- скрываем модель в случае успешной проверки
                        return true                     -- если событие обработано, возвращаем true
                end
        end
        return false                            -- если событие не обработано, возвращаем false
end)

viewer:addEventHandler(eventsHandler)           -- добавляем обработчик событий к окну

Подробно разберем код: Мы создали обработчик событий - переменную eventsHandler, присвоив ей объект класса osgGA.GUIEventHandler. В конструктор объекта мы передали функцию, которая принимает два аргумента - ea и aa, где ea это объект класса osgGA.GUIEventAdapter, а aa - объект класса osgGA.GUIActionAdapter. Нас интересует ea (osgGA.GUIEventAdapter), который позволяет получить информацию о наступившем событии. Так, метод osgGA.GUIEventAdapter.getEventType() возвращает тип события, а метод osgGA.GUIEventAdapter.getKey() вернет нажатую клавишу. Соотвественно мы проверяем соответствует ли событие нажатой клавише H, и в случае, если это так, скрываем модель, вызвав ее метод Узел.Объект - скрыть (NodeReactor.hide). Последним шагом является добавление нашего обработчика событий к объекту Viewer (ViewerReactor), который используется для настройки параметров отображения сцены в окне программы.

Примечание

Все реакторы, унаследованные от Узел (NodeReactor) (такие как модель, например) обладают методами Узел.Объект - показать (NodeReactor.show) и Узел.Объект - скрыть (NodeReactor.hide), которые позволяют показать и скрыть реактор соотвественно.

Шаг 4

Сделаем так, чтобы наша модель появлялась по нажатии клавиши V, переработав код следующим образом:

local eventsHandler = osgGA.GUIEventHandler(function(ea, aa)
        if ea:getEventType() == osgGA.GUIEventAdapter.KEYDOWN then
                if ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_H) then
                        logoModel:hide()
                        return true
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_V) then  -- проверяем, нажата ли клавиша V
                        logoModel:show()                -- если да, то скрываем ее
                        return true
                end
        end
        return false
end)

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

Шаг 5

Усложним задачу. Что если мы хотим, чтобы модель исчезала не сразу, а через, к примеру, 5 секунд после нажатия. Для решения этой задачи нам понадобится объект Таймер (TimerReactor), уже присутствующий в проекте(«hide_timer»). По нажатии клавиши запустим его, и как он сработает - скроем модель. Добавим в код следующее и переработаем обработчик событий:

local timer = reactorController:getReactorByName("hide_timer")

timer:subscribeEvent("onAlarm", function() -- подписываем на событие таймера функцию, скрывающую модель
        logoModel:hide()
end)

local eventsHandler = osgGA.GUIEventHandler(function(ea, aa)
        if ea:getEventType() == osgGA.GUIEventAdapter.KEYDOWN then
                if ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_H) then
                        timer:start(5, TimerReactor.Mode.ONCE) -- запускаем таймер
                        return true
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_V) then
                        logoModel:show()
                        return true
                end
        end
        return false
end)

Разберем код: так же, как и с моделью, мы получили реактор таймера с помощью reactorController и его метода getReactorByName(). Затем мы подписали на событие таймера Таймер.Звонок (TimerReactor.onAlarm) функцию, в которой всего одна строчка кода - скрытие модели. И переработали обработчик событий таким образом, что он теперь не скрывает модель, а запускает таймер с параметрами 5 секунд и режимом TimerReactor.Mode.ONCE - запустить один раз.

Важно

Подписка функций на события реакторов является ключевым способом взаимодействия объектов в сцене. Например, по наступлении какого-либо события могут быть скрыты, показаны или модифицированы элементы интерфейса, либо настроено взаимодействие объектов. Все реакторы обладают методом subscribeEvent, который принимает два параметра - название события и функцию, которая будет вызвана по наступлении этого события.

Шаг 6

Наша модель теперь исчезает по таймеру, но как бы это визуализировать? Реализуем обратный отсчет в графическом интерфейсе, добавив следующие строки кода:

local timerText = reactorController:getReactorByName("timer_text")
local countdownInitial          = 5
local countdownValue            = nil

В данном коде мы получаем реактор текстового объекта, который как раз и будет нашим обратным отсчетом от 5 до 0. Затем инициализируем начальное значение отсчета и текущее значение, которое пока не определено. Переработаем часть обработчика событий, отвечающего за нажатие на клавишу H:

if ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_H) and countdownValue == nil then -- не запускаем таймер, если обратный отсчет уже запущен(countDownValue ~= nil)
                timer:start(1, TimerReactor.Mode.LOOP)
                countdownValue = countdownInitial       -- начальное значение обратного отсчета
                timerText:show()        -- показываем текстовый объект
                timerText:setText_value(countdownValue)         -- устанавливаем значение текста числом
                return true
...

Теперь таймер запускается на 1 секунду в режиме TimerReactor.Mode.LOOP, т.е. повторяясь, пока мы его сами не выключим. Также переработаем подписку таймера:

timer:subscribeEvent("onAlarm", function()
        countdownValue = countdownValue - 1 -- каждый звонок значение счетчика уменьшается на 1
        if countdownValue == 0 then -- если значение счетчика 0, то
                countdownValue = nil    -- сбрасываем значение счетчика
                timer:reset()           -- сбрасываем таймер
                timerText:hide()        -- скрываем текст
                logoModel:hide()        -- скрываем модель
                return
        end
        timerText:setText_value(countdownValue)         -- если значение счетчика > 0, то устанавливаем текст
end)

Шаг 7

Далее сделаем перемещение нашей модели клавишами стрелок на клавиатуре. Сделать это не сложно. Добавим следующие строки кода:

local step                                      = 0.01

function moveModel(translation)
        local translationMatrix = osg.Matrix.translate(translation)
        logoModel:setMatrix(logoModel:getMatrix()*translationMatrix)
end

Мы создали переменную step, равную 0.01, это и будет наше смещение, которое измеряется в метрах(соответственно step равен одному сантиметру). Далее мы создали функцию moveModel(), которая принимает аргументом смещение. Для манипуляции объектами в трехмерном прострнатсве используются вектора и матрицы. Параметр translation, который принимает функция moveModel() это вектор, который создается с помощью конструктора osg.Vec3. Далее мы создаем матрицу смещения с помощью статического метода osg.Matrix.translate(), в который параметром передаем наш вектор смещения. И последним шагом будет задание матрицы для нашей модели с помощью метода модели setMatrix(), который параметром принимает матрицу трансформации.

Примечание

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

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

if ea:getEventType() == osgGA.GUIEventAdapter.KEYDOWN then
        if ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Up) then
                moveModel(osg.Vec3(0, step, 0))         -- перемещение по оси Y на шаг вперед
                return true
        elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Down) then
                moveModel(osg.Vec3(0, -step, 0))        -- перемещение по оси Y на шаг назад
                return true
        elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Left) then
                moveModel(osg.Vec3(-step, 0.0, 0))      -- перемещение по оси X на шаг влево
                return true
        elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Right) then
                moveModel(osg.Vec3(step, 0, 0))         -- перемещение по оси X на шаг вправо
                return true
        elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_H) and countdownValue == nil then
        ...

Готово! Теперь наша модель перемещается по нажатию клавиш стрелок.

Шаг 8

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

Сначала добавим следующие переменные:

local touchZone = reactorController:getReactorByName("touch_zone")

local initialMatrix                     = nil
local rotateModeEnabled                 = false
local rotationAxis                      = osg.Vec3(0.0, 0.0, 1.0)

touchZone - это реактор нашего прямоугольника в интерфейсе. initialMatrix - переменная, в которой мы будем запоминать матрицу трансформации объекта модели перед применением вращения к ней. rotateModeEnabled - флаг, который мы будет ставить в true, если вращение началось. rotationAxis - вектор, задающий ось вращения, в данной случаем вращение будет вокруг оси Z.

touchZone:subscribeEvent("onDown", function()
        if not logoModel:getVisible() then return end   -- если модель скрыта, то ничего не делаем

        rotateModeEnabled = true        -- устанавливаем режим вращения
        initialMatrix = logoModel:getMatrix()   -- запоминаем начальную матрицу перед началом вращения
end)

Далее мы подписываемся на событие NodeReactor.onDown, которое наследуется прямоугольником от Узел (NodeReactor) Осталось только обработать нажатие и перетаскивание мыши в нашем обработчике событий:

        ...
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_V) then
                logoModel:show()
                return true
        end
elseif ea:getEventType() == osgGA.GUIEventAdapter.RELEASE then  -- если отпустили кнопку мыши, сбрасываем переменные
        rotateModeEnabled = false
        clickPoint = nil
elseif ea:getEventType() == osgGA.GUIEventAdapter.DRAG and rotateModeEnabled then       -- если тащим с зажатой кнопкой мыши и установлен режим вращения
        local radiansPerPixel = 2.0 * math.pi / ea:getWindowWidth()             -- считаем количество радиан на пиксель в зависимости от ширины окна
        if not clickPoint then clickPoint = { x = ea:getX(), y = ea:getY() } end        -- таблица, которая запоминает координаты точки начального нажатия

        local rotationMatrix = osg.Matrix.rotate(osg.Quat((ea:getX() - clickPoint.x)*radiansPerPixel, rotationAxis))

        logoModel:setMatrix(initialMatrix * rotationMatrix)
end
return false
...

Подробно разберем код. В наш обработчик добавились два события - osgGA.GUIEventAdapter.RELEASE и osgGA.GUIEventAdapter.DRAG. Первый срабатывает, когда кнопка мыши отпущена после нажатия и сбрасывает вспомогательные переменные. Во втором написана основная логика вращения. Сначала мы запоминаем координаты точки, в которой произошло нажатие(это сработает только один раз). Затем конструируем нашу матрицу вращения, которая принимает аргументом кватернион вращения, который, в свою очередь, состоит из количества радиан и оси вращения. Ось вращения мы указали в начале файла в локальных переменных, количество же радиан считается исходя из разницы координат текущего положения мыши и начального(в котором произошло нажатие). И последним шагом будет установка матрицы трансформации для модели логотипа.

Шаг 9

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

local decomposedMatrix          = {}

touchZone:subscribeEvent("onDown", function()
        if not logoModel:getVisible() then return end

        rotateModeEnabled = true
        initialMatrix = logoModel:getMatrix()
        local trans, rotation, scale = initialMatrix:decompose()
        decomposedMatrix.transM = osg.Matrix.translate(trans)   -- матрица сдвига
        decomposedMatrix.rotateM = osg.Matrix.rotate(rotation)  -- матрица вращения
        decomposedMatrix.scaleM = osg.Matrix.scale(scale)       -- матрица масштабирования
end)

С чем связано вращение вокруг центра координат? С порядком применения матриц трансформации. В предыдущем шаге умножение матриц работало так, что мы сначала сдвигали модель, а затем уже вращали, и вращение поэтому происходило относительно центра координат. В нашем же случае, чтобы модель вращалась вокруг своей оси, нужно сначала ее поворачивать, а затем уже сдвигать. Но как это сделать, ведь начальная матрица содержит и поворот, и вращение? Ответ прост - необходимо разложить матрицу на составляющие: сдвиг, вращение и масштабирование, для чего у всех матриц присутствует метод osg.Matrix.decompose(). В приведенном коде с помощью этого метода и множественного присваивания, мы сконструировали три независимые матрицы и положили их в таблицу decomposedMatrix. Далее мы учитываем это в нашем обработчике:

...
elseif ea:getEventType() == osgGA.GUIEventAdapter.DRAG and rotateModeEnabled then
        local radiansPerPixel = 2.0 * math.pi / ea:getWindowWidth()
        if not clickPoint then clickPoint = { x = ea:getX(), y = ea:getY() } end

        local rotationMatrix = osg.Matrix.rotate(osg.Quat((ea:getX() - clickPoint.x)*radiansPerPixel, rotationAxis))

        logoModel:setMatrix(decomposedMatrix.scaleM * decomposedMatrix.rotateM * rotationMatrix * decomposedMatrix.transM)
end
...

Подведем итоги

В этом руководстве мы научились:

  • Добавлять скрипты в проект
  • Получать реактор любого объекта
  • Обрабатывать события мыши и клавиатуры
  • Подписываться на события реакторов и выполнять функции при наступлении этих событий
  • Работать с векторами, матрицами и кватернионами
  • Задавать трансформацию объекта с помощью матрицы
  • Вызывать методы объектов
  • Скрывать и показывать объекты
  • Взаимодействовать с интерфейсом

Полный код

local logoModel = reactorController:getReactorByName("logo")
local timer = reactorController:getReactorByName("hide_timer")
local timerText = reactorController:getReactorByName("timer_text")
local touchZone = reactorController:getReactorByName("touch_zone")

local rotationAxis                      = osg.Vec3(0.0, 0.0, 1.0)
local initialMatrix                     = nil
local countdownInitial                  = 5
local countdownValue                    = nil
local step                              = 0.01
local rotateModeEnabled                 = false
local decomposedMatrix                  = {}


touchZone:subscribeEvent("onDown", function()
        if not logoModel:getVisible() then return end

        rotateModeEnabled = true
        initialMatrix = logoModel:getMatrix()
        local trans, rotation, scale = initialMatrix:decompose()
        decomposedMatrix.transM = osg.Matrix.translate(trans)
        decomposedMatrix.rotateM = osg.Matrix.rotate(rotation)
        decomposedMatrix.scaleM = osg.Matrix.scale(scale)
end)

function moveModel(translation)
        local translationMatrix = osg.Matrix.translate(translation)
        logoModel:setMatrix(logoModel:getMatrix()*translationMatrix)
end

timer:subscribeEvent("onAlarm", function()
        countdownValue = countdownValue - 1
        if countdownValue == 0 then
                countdownValue = nil
                timer:reset()
                timerText:hide()
                logoModel:hide()
                return
        end
        timerText:setText_value(countdownValue)
end)

local eventsHandler = osgGA.GUIEventHandler(function(ea, aa)
        if ea:getEventType() == osgGA.GUIEventAdapter.KEYDOWN then
                if ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Up) and not rotateModeEnabled then
                        moveModel(osg.Vec3(0, step, 0))
                        return true
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Down) and not rotateModeEnabled then
                        moveModel(osg.Vec3(0, -step, 0))
                        return true
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Left) and not rotateModeEnabled then
                        moveModel(osg.Vec3(-step, 0.0, 0))
                        return true
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_Right) and not rotateModeEnabled then
                        moveModel(osg.Vec3(step, 0, 0))
                        return true
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_H) and countdownValue == nil then
                        timer:start(1, TimerReactor.Mode.LOOP)
                        countdownValue = countdownInitial
                        timerText:show()
                        timerText:setText_value(countdownValue)
                        return true
                elseif ea:getKey() == bit_or(osgGA.GUIEventAdapter.KEY_V) then
                        logoModel:show()
                        return true
                end
        elseif ea:getEventType() == osgGA.GUIEventAdapter.RELEASE then
                rotateModeEnabled = false
                clickPoint = nil
        elseif ea:getEventType() == osgGA.GUIEventAdapter.DRAG and rotateModeEnabled then
                local radiansPerPixel = 2.0 * math.pi / ea:getWindowWidth()
                if not clickPoint then clickPoint = { x = ea:getX(), y = ea:getY() } end

                local rotationMatrix = osg.Matrix.rotate(osg.Quat((ea:getX() - clickPoint.x)*radiansPerPixel, rotationAxis))

                logoModel:setMatrix(decomposedMatrix.scaleM * decomposedMatrix.rotateM * rotationMatrix * decomposedMatrix.transM)
        end
        return false
end)

viewer:addEventHandler(eventsHandler)