События

Меблируем комнату. Часть 1: Динамическая загрузка

2014-08-15

Продолжая тему интерактивности в 3D-приложениях, рассмотрим реализацию приложения "Игровая комната", в котором пользователь сможет обставить комнату предметами мебели.

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

Примечание

Рекомендуем также ознакомиться со статьей "Урок: создание интерактивного веб-приложения", в которой подробно описаны структура и принципы работы минимальной программы на основе Blend4Web.

Подготовка файлов сцен

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

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

Примечание

При динамической загрузке к основной сцене добавляются все объекты за исключением источников освещения и камер из вторичных сцен.

а) Основная сцена

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

При выборе объектов мебели будем подсвечивать их эффектом Outline. Цвет эффекта по умолчанию может быть установлен на панели Render > Object Outlining. Мы будем изменять его на красный программно для индикации коллизий с другими объектами.

Примечание

Динамически загружаемые сцены не влияют на настройки основной сцены, поэтому цвет эффекта Outline имеет смысл выставлять только в главном blend-файле.

На панели Render > Object Outlining и на панели Scene > Objects Selection выставим опцию Enable в положение ON. Это следует делать в случае, когда сцена изначально не содержит доступные для выбора и подсветки объекты, но потом они появятся на ней в результате динамической загрузки.


б) Дополнительные сцены

Разместим предварительно созданные модели мебели по одному в каждом blend-файле.

Выставим необходимые настройки на панели Object. Во-первых, включим опцию Rendering Properties > Force Dynamic Object, чтобы объект можно было перемещать в приложении. Во-вторых, включим опции Selection and Outlining > Selectable и Selection and Outlining > Enable Outlining для возможности выбирать и подсвечивать объект.

Все 3D-сцены, созданные на предыдущем этапе, экспортируем с помощью меню File > Export > Blend4Web (.json). В нашем случае экспортированные файлы находятся в директории blend_data/.

Инициализация приложения

Приведем код главного модуля приложения cartoon_interior.js целиком:

"use strict";

b4w.register("cartoon_interior", function(exports, require) {

var m_app       = require("app");
var m_cam       = require("camera");
var m_cont      = require("container");
var m_ctl       = require("controls");
var m_data      = require("data");
var m_mouse     = require("mouse");
var m_math      = require("math");
var m_obj       = require("objects");
var m_phy       = require("physics");
var m_preloader = require("preloader");
var m_scenes    = require("scenes");
var m_trans     = require("transform");
var m_util      = require("util");

var m_quat = require("quat");

var OUTLINE_COLOR_VALID = [0, 1, 0];
var OUTLINE_COLOR_ERROR = [1, 0, 0];
var FLOOR_PLANE_NORMAL = [0, 1, 0];

var ROT_ANGLE = Math.PI/4;

var WALL_X_MAX = 4;
var WALL_X_MIN = -3.8;
var WALL_Z_MAX = 4.2;
var WALL_Z_MIN = -3.5;

var _obj_delta_xy = new Float32Array(2);
var spawner_pos = new Float32Array(3);
var _vec3_tmp = new Float32Array(3);
var _vec3_tmp2 = new Float32Array(3);
var _vec3_tmp3 = new Float32Array(3);
var _vec4_tmp = new Float32Array(4);
var _pline_tmp = m_math.create_pline();

var _drag_mode = false;
var _enable_camera_controls = true;

var _selected_obj = null;

exports.init = function() {
    m_app.init({
        canvas_container_id: "main_canvas_container",
        callback: init_cb,
        physics_enabled: true,
        alpha: false,
        background_color: [1.0, 1.0, 1.0, 0.0]
    });
};

function init_cb(canvas_elem, success) {

    if (!success) {
        console.log("b4w init failure");
        return;
    }

    m_preloader.create_preloader();

    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);

    window.onresize = m_cont.resize_to_container;
    m_cont.resize_to_container();
    load();
}

function preloader_cb(percentage) {
    m_preloader.update_preloader(percentage);
}

function load() {
    m_data.load("blend_data/environment.json", load_cb, preloader_cb);
}

function load_cb(data_id) {
    m_app.enable_camera_controls(false, false, false, m_cont.get_canvas());
    init_controls();

    var spawner = m_scenes.get_object_by_name("spawner");
    m_trans.get_translation(spawner, spawner_pos);
}

function init_controls() {
    var controls_elem = document.getElementById("controls-container");
    controls_elem.style.display = "block";

    init_buttons();

    document.getElementById("load-1").addEventListener("click", function(e) {
        m_data.load("blend_data/bed.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-2").addEventListener("click", function(e) {
        m_data.load("blend_data/chair.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-3").addEventListener("click", function(e) {
        m_data.load("blend_data/commode_and_pot.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-4").addEventListener("click", function(e) {
        m_data.load("blend_data/fan.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-5").addEventListener("click", function(e) {
        m_data.load("blend_data/table.json", loaded_cb, null, null, true);
    });

    document.getElementById("delete").addEventListener("click", function(e) {
        if (_selected_obj) {
            var id = m_scenes.get_object_data_id(_selected_obj);
            m_data.unload(id);
            _selected_obj = null;
        }
    });
    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);
    });
}

function init_buttons() {
    var ids = ["delete", "rot-ccw", "rot-cw"];

    for (var i = 0; i < ids.length; i++) {
        var id = ids[i];

        document.getElementById(id).addEventListener("mousedown", function(e) {
            var parent = e.target.parentNode;
            parent.classList.add("active");
        });
        document.getElementById(id).addEventListener("mouseup", function(e) {
            var parent = e.target.parentNode;
            parent.classList.remove("active");
        });
        document.getElementById(id).addEventListener("touchstart", function(e) {
            var parent = e.target.parentNode;
            parent.classList.add("active");
        });
        document.getElementById(id).addEventListener("touchend", function(e) {
            var parent = e.target.parentNode;
            parent.classList.remove("active");
        });
    }
}

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);


            // spawn appended object at a certain position
            var obj_parent = m_obj.get_parent(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, spawner_pos);
            else
                m_trans.set_translation_v(obj, spawner_pos);
        }

        // show appended object
        if (m_obj.is_mesh(obj))
            m_scenes.show_object(obj);
    }
}

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

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);
    }
}

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 main_canvas_down(e) {
    _drag_mode = true;

    if (e.preventDefault)
        e.preventDefault();

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

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

    // handling outline effect
    if (_selected_obj != obj) {
        if (_selected_obj)
            m_scenes.clear_outline_anim(_selected_obj);
        if (obj)
            m_scenes.apply_outline_anim(obj, 1, 1, 0);

        _selected_obj = obj;
    }

    // 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];
    }
}

function main_canvas_up(e) {
    _drag_mode = false;
    // enable camera controls after releasing the object
    if (!_enable_camera_controls) {
        m_app.enable_camera_controls();
        _enable_camera_controls = true;
    }
}

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);
                }
            }
        }
}

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);
}

});

b4w.require("cartoon_interior").init();

Как обычно, инициализация происходит с помощью функции init():

exports.init = function() {
    m_app.init({
        canvas_container_id: "main_canvas_container",
        callback: init_cb,
        physics_enabled: true,
        alpha: false,
        background_color: [1.0, 1.0, 1.0, 0.0]
    });
};

Как было указано в предыдущем уроке, динамическое изменение размеров Canvas-элемента можно включить опцией autoresize при инициализации приложения. Это простой, но не самый оптимальный способ. В некоторых браузерах это может приводить к уменьшению FPS.

Для более сложных приложений рекомендуется использовать метод resize_to_container() из модуля container.js. Он приводит размеры элемента Canvas в соответствие с размерами его контейнера, определенного параметром canvas_container_id при инициализации приложения.

Для "реагирования" на поведение браузера используем обработчик события window.onresize:

function init_cb(canvas_elem, success) {
    ...
    window.onresize = m_cont.resize_to_container;
    m_cont.resize_to_container();
    ...
}

Таким образом, будет достаточно настроить CSS стили элемента-контейнера, например, как это делается по умолчанию:

div#main_canvas_container {
    position: absolute;
    width: 100%;
    height: 100%;
    left: 0;
    top: 0;
}

В нашем примере Canvas всегда будет следовать размерами за элементом-контейнером main_canvas_container, занимающим все окно браузера.


Динамическая загрузка

Загрузка дополнительных сцен реализована в функции init_controls() и непосредственно связана с интерфейсом приложения - объекты мебели добавляются при нажатии пользователем соответствующей кнопки:

function init_controls() {
    ...
    document.getElementById("load-1").addEventListener("click", function(e) {
        m_data.load("blend_data/bed.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-2").addEventListener("click", function(e) {
        m_data.load("blend_data/chair.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-3").addEventListener("click", function(e) {
        m_data.load("blend_data/commode_and_pot.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-4").addEventListener("click", function(e) {
        m_data.load("blend_data/fan.json", loaded_cb, null, null, true);
    });
    document.getElementById("load-5").addEventListener("click", function(e) {
        m_data.load("blend_data/table.json", loaded_cb, null, null, true);
    });
    ...
}

Загрузка дополнительных сцен происходит с помощью метода data.load() - т.е. точно также, как и основной сцены. По завершении загрузки будет вызвана функция-обработчик loaded_cb().

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

Точка, в которую будут помещаться новые объекты, была обозначена размещенным в нужном месте сцены пустым объектом ("EMPTY"). После загрузки основной сцены сохраняем координаты этого пустого объекта (названного "spawner") в соответствующей переменной:

...
var spawner_pos = new Float32Array(3);
...

function load_cb(data_id) {
    ...
    var spawner = m_scenes.get_object_by_name("spawner");
    m_trans.get_translation(spawner, spawner_pos);
    ...
}

После загрузки дополнительной сцены подготавливаем объекты для отображения:

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);

            ...

            // spawn appended object at a certain position
            var obj_parent = m_obj.get_parent(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, spawner_pos);
            else
                m_trans.set_translation_v(obj, spawner_pos);
        }

        // show appended object
        if (m_obj.is_mesh(obj))
            m_scenes.show_object(obj);
    }
}

Рассмотрим этот код подробнее.

При динамической загрузке происходит добавление объектов на основную сцену приложения. При этом каждая загруженная сцена имеет порядковый номер (0 - основная сцена, 1,2,3,... - последующие), который присваивается каждому объекту этой сцены.

Используя этот идентификатор, найдем вновь загруженные объекты:

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

Затем включаем для них физическую симуляцию:

m_phy.enable_simulation(obj);

...перемещаем объект в нужную точку:

...
// spawn appended object at a certain position
var obj_parent = m_obj.get_parent(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, spawner_pos);
else
    m_trans.set_translation_v(obj, spawner_pos);
...

...и включаем отображение только объектов типа MESH:

...
// show appended object
if (m_obj.is_mesh(obj))
    m_scenes.show_object(obj);
...

Выгрузка

При нажатии на кнопку удаления, выгрузим выбранный объект из приложения:

function init_controls() {
    ...
    document.getElementById("delete").addEventListener("click", function(e) {
        if (_selected_obj) {
            var id = m_scenes.get_object_data_id(_selected_obj);
            m_data.unload(id);
            _selected_obj = null;
        }
    });
    ...
}

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

Порядковый номер сцены можно узнать по любому принадлежащему ей объекту:

var id = m_scenes.get_object_data_id(_selected_obj);

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

Заключение

На этом о динамической загрузке всё. В следующей части мы подробно остановимся на настройках физики и взаимодействии пользователя с объектами.

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

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

Изменения

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

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

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

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

[2015-04-23] Правки текста об исходных файлах приложения.

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

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

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

[2015-10-05] Небольшие правки текста.

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