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

Шаг 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)