События

Меблируем комнату. Часть 2: Интерактивность и физика

2014-08-22

Продолжаем описание приложения "Игровая комната". В предыдущей статье цикла была рассмотрена технология динамической загрузки. Теперь же мы уделим внимание таким важным компонентам приложения как физика и система управления.

Настройка физики

Напомним, что для приложения были подготовлены несколько сцен: главная сцена, содержащая комнату, и дополнительные ‒ с объектами мебели.

Объекты мебели будут физически взаимодействовать между собой, но довольно простым образом - сигнализировать, если появится пересечение с другим таким же объектом. Для этого в главной сцене необходимо разрешить физическую симуляцию, а именно, во вкладке Scene > Physics активировать опцию Enable Physics.

Что касается дополнительных сцен, то для каждого объекта мебели нужно настроить физические свойства, т.к. мы хотим отслеживать их пересечения друг с другом. Это можно сделать во вкладке Physics.

На панели Physics включим Object Physics и в поле Collision ID назначим идентификатор для этого объекта. Для всех объектов введем одно и то же значение: FURNITURE.

При выборе типа физического объекта Physics Type выставим значение Static. Это кажется неинтуитивным на первый взгляд, поскольку эти объекты будут перемещаться пользователем. Тем не менее в данном случае нам требуется именно такой режим физического взаимодействия, при котором для объектов определяются столкновения, но сами они не перемещаются под действием сил.

Далее включим опцию Ghost, что позволит детектировать контакт с другими телами, но не будет препятствовать их пересечению.

Для каждого объекта выберем наиболее подходящий тип ограничивающего объема Collision Bounds > Bounds. Поддерживаются следующие типы: Capsule, Box, Sphere, Cylinder и Cone. Выставим также тип AUTO в поле Bounding Box Correction для более корректной генерации ограничивающего объема.

Более подробно настройки физики описаны в соответствующем разделе документации.

Интерактивность: повороты

В приложении "Игровая комната" у пользователя есть возможность расставлять объекты мебели, т.е. поворачивать их и перемещать в пределах комнаты.

Вращение объекта мебели происходит по нажатию соответствующих кнопок в интерфейсе приложения:

function init_controls() {
    ...
    document.getElementById("rot-ccw").addEventListener("click", function(e) {
        if (_selected_obj)
            rotate_object(_selected_obj, ROT_ANGLE);
    });
    document.getElementById("rot-cw").addEventListener("click", function(e) {
        if (_selected_obj)
            rotate_object(_selected_obj, -ROT_ANGLE);
    });
    ...
}

За реализацию отвечает функция rotate_object(). В ней используется кватернион вращения объекта, который мы поворачиваем на угол angle вокруг вертикальной оси и вновь присваиваем объекту:

function rotate_object(obj, angle) {
    var obj_parent = m_obj.get_parent(obj);
    
    if (obj_parent && m_obj.is_armature(obj_parent)) {
        // rotate the parent (armature) of the animated object
        var obj_quat = m_trans.get_rotation(obj_parent, _vec4_tmp);
        m_quat.rotateY(obj_quat, angle, obj_quat);
        m_trans.set_rotation_v(obj_parent, obj_quat);
    } else {
        var obj_quat = m_trans.get_rotation(obj, _vec4_tmp);
        m_quat.rotateY(obj_quat, angle, obj_quat);
        m_trans.set_rotation_v(obj, obj_quat);
    }
    limit_object_position(obj);
}

Интерактивность: перемещение

Перемещать объекты мебели можно с помощью мыши либо скользящим движением на тачскрине.

Начнем с того, что зарегистрируем необходимые обработчики событий:

function init_cb(canvas_elem, success) {
    ...
    canvas_elem.addEventListener("mousedown", main_canvas_down);
    canvas_elem.addEventListener("touchstart", main_canvas_down);

    canvas_elem.addEventListener("mouseup", main_canvas_up);
    canvas_elem.addEventListener("touchend", main_canvas_up);

    canvas_elem.addEventListener("mousemove", main_canvas_move);
    canvas_elem.addEventListener("touchmove", main_canvas_move);
    ...
}

При нажатии вызовется обработчик main_canvas_down(). Здесь мы получаем экранные координаты точки нажатия и определяем выделенный объект:

function main_canvas_down(e) {
    ...

    var x = m_mouse.get_coords_x(e);
    var y = m_mouse.get_coords_y(e);

    var obj = m_scenes.pick_object(x, y);

    ...

    // calculate delta in viewport coordinates
    if (_selected_obj) {
        var cam = m_scenes.get_active_camera();

        var obj_parent = m_obj.get_parent(_selected_obj);
        if (obj_parent && m_obj.is_armature(obj_parent))
            // get translation from the parent (armature) of the animated object
            m_trans.get_translation(obj_parent, _vec3_tmp);
        else
            m_trans.get_translation(_selected_obj, _vec3_tmp);
        m_cam.project_point(cam, _vec3_tmp, _obj_delta_xy);

        _obj_delta_xy[0] = x - _obj_delta_xy[0];
        _obj_delta_xy[1] = y - _obj_delta_xy[1];
    }
}

При перемещении сработает функция main_canvas_move(), которая обеспечит следование объекта за курсором. Для удобства управления объектом мебели будем в этот момент отключать управление камерой:

function main_canvas_move(e) {
    if (_drag_mode)
        if (_selected_obj) {
            // disable camera controls while moving the object
            if (_enable_camera_controls) {
                m_app.disable_camera_controls();
                _enable_camera_controls = false;
            }

            // calculate viewport coordinates
            var cam = m_scenes.get_active_camera();

            var x = m_mouse.get_coords_x(e);
            var y = m_mouse.get_coords_y(e);

            if (x >= 0 && y >= 0) {
                x -= _obj_delta_xy[0];
                y -= _obj_delta_xy[1];

                // emit ray from the camera
                var pline = m_cam.calc_ray(cam, x, y, _pline_tmp);
                var camera_ray = m_math.get_pline_directional_vec(pline, _vec3_tmp);

                // calculate ray/floor_plane intersection point
                var cam_trans = m_trans.get_translation(cam, _vec3_tmp2);
                m_math.set_pline_initial_point(_pline_tmp, cam_trans);
                m_math.set_pline_directional_vec(_pline_tmp, camera_ray);
                var point = m_math.line_plane_intersect(FLOOR_PLANE_NORMAL, 0,
                        _pline_tmp, _vec3_tmp3);

                // do not process the parallel case and intersections behind the camera
                if (point && camera_ray[1] < 0) {
                    var obj_parent = m_obj.get_parent(_selected_obj);
                    if (obj_parent && m_obj.is_armature(obj_parent))
                        // translate the parent (armature) of the animated object
                        m_trans.set_translation_v(obj_parent, point);
                    else
                        m_trans.set_translation_v(_selected_obj, point);
                    limit_object_position(_selected_obj);
                }
            }
        }
}

Чтобы определить будущее местоположение объекта, мы рассчитываем координаты точки в пространстве области отрисовки, куда должен проецироваться центр объекта после перемещения. Далее строим трехмерный вектор, направленный из позиции камеры в направлении этой точки, и находим пересечение прямой, содержащей этот вектор, с плоскостью пола комнаты. Точка пересечения и будет искомым местоположением. Далее остается только переместить туда объект.

Рассмотрим подробнее функцию line_plane_intersect():

var cam_trans = m_trans.get_translation(cam, _vec3_tmp2);
m_math.set_pline_initial_point(_pline_tmp, cam_trans);
m_math.set_pline_directional_vec(_pline_tmp, camera_ray);

var point = m_math.line_plane_intersect(FLOOR_PLANE_NORMAL, 0,
        _pline_tmp, _vec3_tmp3);

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

Наконец, в методе main_canvas_up() после завершения движения снова включим управление камерой:

function main_canvas_up(e) {
    ...
    if (!_enable_camera_controls) {
        m_app.enable_camera_controls();
        _enable_camera_controls = true;
    }
    ...
}

Размещение в пределах комнаты

Управление объектами мебели реализовано таким образом, чтобы они двигались параллельно плоскости пола. Чтобы избежать их выхода за пределы комнаты, используем функцию limit_object_position():

function limit_object_position(obj) {
    var bb = m_trans.get_object_bounding_box(obj);

    var obj_parent = m_obj.get_parent(obj);
    if (obj_parent && m_obj.is_armature(obj_parent))
        // get translation from the parent (armature) of the animated object
        var obj_pos = m_trans.get_translation(obj_parent, _vec3_tmp);
    else
        var obj_pos = m_trans.get_translation(obj, _vec3_tmp);

    if (bb.max_x > WALL_X_MAX)
        obj_pos[0] -= bb.max_x - WALL_X_MAX;
    else if (bb.min_x < WALL_X_MIN)
        obj_pos[0] += WALL_X_MIN - bb.min_x;

    if (bb.max_z > WALL_Z_MAX)
        obj_pos[2] -= bb.max_z - WALL_Z_MAX;
    else if (bb.min_z < WALL_Z_MIN)
        obj_pos[2] += WALL_Z_MIN - bb.min_z;

    if (obj_parent && m_obj.is_armature(obj_parent))
        // translate the parent (armature) of the animated object
        m_trans.set_translation_v(obj_parent, obj_pos);
    else
        m_trans.set_translation_v(obj, obj_pos);
}

Поясним кратко происходящее в ней. Во-первых, исходя из модели комнаты известны координаты её стен. В скрипте они заданы в виде констант WALL_X_MAX, WALL_X_MIN, WALL_Z_MAX и WALL_Z_MIN. Во-вторых, для объектов мебели (как и для всех объектов вообще) доступны координаты ограничивающего объема (bounding box). Всё вместе это позволяет отслеживать выход за границу и корректировать местоположение объекта.

Определение пересечений

Для большей наглядности и удобства при расстановке мебели будут отображаться пересечения между объектами. Делать это будем эффектом Outline:

Реализация будет опираться на систему сенсоров.

После динамической загрузки каждой сцены происходит вызов обработчика loaded_cb(), который был передан в качестве параметра в data.load():

function init_controls() {
    ...
    document.getElementById("load-1").addEventListener("click", function(e) {
        m_data.load("blend_data/bed.json", loaded_cb, null, null, true);
    });
    ...
}

function loaded_cb(data_id) {

    var objs = m_scenes.get_all_objects("ALL", data_id);
    for (var i = 0; i < objs.length; i++) {
        var obj = objs[i];

        if (m_phy.has_physics(obj)) {
            m_phy.enable_simulation(obj);

            // create sensors to detect collisions
            var sensor_col = m_ctl.create_collision_sensor(obj, "FURNITURE");
            var sensor_sel = m_ctl.create_selection_sensor(obj, true);

            if (obj == _selected_obj)
                m_ctl.set_custom_sensor(sensor_sel, 1);

            m_ctl.create_sensor_manifold(obj, "COLLISION", m_ctl.CT_CONTINUOUS, 
                    [sensor_col, sensor_sel], logic_func, trigger_outline);
        
            ...
        }

        ...
    }
}

Здесь мы получаем вновь загруженные объекты:

var objs = m_scenes.get_all_objects("ALL", data_id);

...затем для каждого проверяем, является ли он физическим:

...
if (m_phy.has_physics(obj)) {
...
}
...

...и для объектов с физикой создаём пару сенсоров:

var sensor_col = m_ctl.create_collision_sensor(obj, "FURNITURE");
var sensor_sel = m_ctl.create_selection_sensor(obj, true);

Метод create_collision_sensor() создаёт сенсор, отслеживающий пересечения с другими физическими объектами. Первым параметром подаётся объект obj, на котором регистрируется сенсор, и который будет проверяться на коллизии, а вторым - свойство Collision ID, выставляемое в физических настройках объектов в Blender'е. Таким образом сенсор будет сообщать о пересечении объекта obj с любым объектом, имеющим данный Collision ID.

В нашем примере все объекты мебели имеют идентификатор FURNITURE и взаимодействуют только между собой.

Метод create_selection_sensor() создаёт сенсор, отслеживающий выделенное состояние объекта. Соответственно, если объект выделен, т.е. выбран, например, нажатием мышью, то сенсор сообщит об этом.

Создадим контейнер для сенсоров - сенсорное множество:

m_ctl.create_sensor_manifold(obj, "COLLISION", m_ctl.CT_CONTINUOUS, 
    [sensor_col, sensor_sel], logic_func, trigger_outline);

Поясним переданные параметры:

  • obj - объект, на котором регистрируется множество сенсоров;
  • "COLLISION" - идентификатор сенсорного множества; должен быть уникален для объекта, на котором оно зарегистрировано;
  • CT_CONTINUOUS - тип создаваемого множества выбран таким, чтобы функция-обработчик вызывалась постоянно при положительном значения логической функции;
  • sensors - массив сенсоров, образующий множество;
  • logic_func - логическая функция; её результат совместно с типом множества определяют, когда и как часто будет вызываться функция-обработчик;
  • trigger_outline - функция-обработчик.


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

function logic_func(s) {
    return s[1];
}

Функция trigger_outline довольно проста:

function trigger_outline(obj, id, pulse) {
    if (pulse == 1) {
        // change outline color according to the  
        // first manifold sensor (collision sensor) status
        var has_collision = m_ctl.get_sensor_value(obj, id, 0);
        if (has_collision)
            m_scenes.set_outline_color(OUTLINE_COLOR_ERROR);
        else
            m_scenes.set_outline_color(OUTLINE_COLOR_VALID);
    }
}

Здесь используется аргумент pulse (импульс), генерируемый сенсорным множеством. Импульс зависит от результата логической функции и типа сенсорного множества, в частности для типа CT_CONTINUOUS импульс будет положительным (pulse = 1), если логическая функция истинна, и отрицательным (pulse = -1), если она ложна.

В случае положительного импульса, означающего выбранный объект, будем определять наличие пересечений. Для этого проверим значение соответствующего сенсора:

var has_collision = m_ctl.get_sensor_value(obj, id, 0);

Последний параметр здесь указывает индекс сенсора во множестве, которое, напомним, включает в себя 2 сенсора.

Установим красный цвет эффекта Outline (константа OUTLINE_COLOR_ERROR) при наличии пересечения. В противном случае выделенные объекты будут подсвечиваться зеленым цветом (OUTLINE_COLOR_VALID). Таким образом мы наглядно отобразим пересечения объектов мебели между собой.

Заключение

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

На этом описание программной части приложения "Игровая комната" закончено. Следующая статья цикла будет посвящена созданию моделей приложения в Blender'е.

Исходные файлы приложения и сцены включены в состав бесплатного дистрибутива Blend4Web SDK.

Запустить приложение в отдельном окне

Изменения

[2014-08-22] Изначальная публикация.

[2014-10-29] Обновлен код примера по причине изменения API.

[2014-12-23] Обновлен код примера по причине изменения API.

[2015-04-23] Исправлены некорректные/битые ссылки.

[2015-05-08] Обновлен код примера по причине изменения API.

[2015-05-19] Внесены изменения в код приложения.

[2015-06-26] Внесены изменения в код приложения.

[2015-10-05] Внесены изменения в код приложения.

[2016-08-22] Внесены изменения в код приложения.