Поиск
Лог Рег    Штуки    Проекты

Память и three.js

Данный материал является сборкой моих статей с хабра. В частности Опыта создания нескольких 3д сцен без перезагрузки страницы (three.js) и Продолжаения чистки памяти с three.js.

Что делаю

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

Небольшой опыт работы с three.js у меня был, поэтому, после ознакомления с примерами GLTFLoader, была выбрана именно эта библиотека.

Данные о сценах хранятся в отдельном json файле и содержат информацию о пути к сцене, освещении, маршруте камеры и т.д. но в данном материале они нас интересуют в меньшей мере.

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

Как делаю

Камера

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

  • Камера должна двигаться по ключевым точкам, достраивая промежуточные;
  • камера должна зависеть от скролла колеса мышки (передвигаясь вперед и назад, соответственно);
  • камера должна плавно «тормозить» и сглаживать свое движение.

Для построения и интерполяции пути подошел CatmullRomCurve3 с методом getPoint(), который строил достаточно плавную кривую. Остальные классы кривых, такие как Curve или CubicBezierCurve3 строили промежуточные точки не достаточно плавно. Это следует учитывать.

Привязка скролла мыши к движению камеры достаточно проста. Определив направление скролла, изменяем текущее положение глобальной переменной (прибавкой или вычетом дельты). Полученное значение делим на расстояние от начального положения камеры до ее центра (по иксу). Это связанно с тем, что в моем случае сцена начинается на некотором удалении от центра, а событие перезагрузки модели срабатывает, когда координата камеры (по иксу) ровна нулю. Решение не универсальное, но я придерживался такого подхода, поскольку именно так задумывал проект.

Дополнительная задача была связана и с вращением камеры. Для этого использовался TrackballControls с начальным фокусом в точке (0, 0, 0). При нажатии на кнопки управления (W, S, D, A в данном случае), фокус можно было, аналогично движению по кривой, смягчить (в обоих случая использовал дополнительные таймеры).

Память

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

Не выглядит, как проблема. Однако, фпс продолжает падать. Использую диспетчер задач хрома, чтобы посмотреть, в чем проблема. Выясняется, что память GPU при перезагрузке сцен не очищается, а продолжает забиваться.

Посмотрев материалы на тему использования three.js в SPA (например от Дриеса Де Смета) выяснилось, что очевидный способ удаления элементов сцены не самый правильный.

for (let i = mScene.scene.children.length - 1; i >= 0; i--) {
    mScene.scene.remove(mScene.scene.children[i]); // объекты пропадают, но остаются в памяти
}

Более. Рекомендации к использованию метода очистки геометрии и текстур объекта весьма расплывчаты. Вот что пишут о методе dispose() в документации:

In general, there is no definite recommendation for this. It highly depends on the specific use case when calling dispose() is appropriate. It's important to highlight that it's not always necessary to dispose objects all the time. A good example for this is a game which consists of multiple levels.

 

Очевидно, что в данном проекте необходимо использовать dispose. Но как? Эмпирически пришел к следующему варианту (представлен в сокращенном виде):

dispose_scene() {
    let self = this;
    self.scroll_timer_stop();
    this.scene.traverse(function (object) {
    self.scroll_timer_stop();
        if (object.type === "Mesh" || object.type === "Group") {
            self.dispose_hierarchy(object, self.dispose_node);
            self.scene.remove(object);
            object = null;
       }
    });
}

dispose_hierarchy(node, callback) {
    for (var i = node.children.length - 1; i >= 0; i--) {
        var child = node.children[i];
        this.dispose_hierarchy(child, callback);
        callback(child);
    }
}

dispose_node(node) {
        if (node.constructor.name === "Mesh") {
            node.parent = undefined;
            if (node.geometry) {
                node.geometry.dispose();
            }
            if (node.geometry) {
                node.geometry.dispose();
            }
            let material = node.material;
            if (material) {
                if (material.map) {
                    material.map.dispose();
                }
                if (material.lightMap) {
                    material.lightMap.dispose();
                }
                ...
                material.dispose();
                material = undefined;
            }
        } else if (node.constructor.name === "Object3D") {
            node.parent.remove(node);
            node.parent = null;
        }
}

dispose_postprocessing() { 
        this.postprocessing.rtTextureColors.dispose();
        this.postprocessing.rtTextureDepth.dispose();
        ...
        this.postprocessing.materialGodraysDepthMask.dispose();
        this.postprocessing.materialGodraysGenerate.dispose();
        ...
}

 

Итог

В итоге хотелось бы сказать, что текущие механизмы работы с памятью в three.js не очень прозрачные и очевидные. this.postprocessing.dispose() в предыдущем примере не так влияет на общий расход памяти, а вот пошаговое и методичное удаление всех составляющих, к которым применим dispose() приводит к тому, что ты перестаешь платить ресурсами за то, чего не видишь. Хотя, безусловно, комплексного решения для очистки я не нашел. Более. Нагрузка на видеокарту при работе страницы огромная. Geforce 2070 super выдает стабильный фпс и плавность анимаций, но демонстрирует следующую картину:

С исходным кодом и примерами описанного этапа можно ознакомиться в моем гитхабе.

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

Дополнение

Изучая различные примеры сборки мусора на three.js заинтересовал подход, предложенный на threejsfundamentals.org. Однако, реализовав предложенную конфигурацию и завернув в this.track() все материалы и геометрию, выяснилось, что при загрузке новых сцен нагрузка на GPU продолжает расти. Более того, предложенный пример некорректно работает с EffectComposer и другими классами для постобработки, поскольку в этих классах track() использовать нельзя.

Решение с добавлением ResourceTracker во все используемые классы не привлекает, по очевидным причинам, поэтому решил дополнить метод очистки упомянутого класса. Вот некоторые приемы, которые были использованы:

Прием 1. Грубый

Добавляем renderer.info после метода очистки. Поочередно убираем ресурсы из приложения, чтобы понять, какие из них составляют нагрузку и прячутся в текстурах или материалах. Это не способ решение проблем, а просто способ отладки о котором кто-то мог не знать.

Прием 2. Долгий

Открыв код используемого класса (например AfterimagePass, который можно найти на гитхабе three.js) смотрим, где создаются ресурсы, которые нам нужно очищать, чтобы поддерживать число геометрий и материалов в требуемых рамках.

this.textureComp = new WebGLRenderTarget( window.innerWidth, window.innerHeight, { ... }

То что надо. Согласно документации, WebGLRenderTarget имеет функцию dispose, на которой завязана очистка памяти. Получаем что-то вроде:

class Scene {
//...
    postprocessing_init(){ // В нашем классе
        this.afterimagePass = new AfterimagePass(0);
        this.composer.addPass(this.afterimagePass);
    }
//...
}
//...

class ResourceTracker {
//...
    dispose() {
    //...
    sceneObject.afterimagePass.WebGLRenderTarget.dispose();
    //...
    }
}

Прием 3

Работает, но код для очистки в таком случае раздувается. Попробуем использовать знакомый нам из предыдущей статьи подход. Напомню, в ней мы реализовали метод disposeNode(node), в котором ресурс перебирался на поиск того, что можно очистить. disposeNode() может выглядеть как-то так:

disposeNode(node) {
            node.parent = undefined;
            if (node.geometry) {
                node.geometry.dispose();
            }
            let material = node.material;
            if (material) {
                if (material.map) {
                    material.map.dispose();
                }
                if (material.lightMap) {
                    material.lightMap.dispose();
                }
                if (material.bumpMap) {
                    material.bumpMap.dispose();
                }
                if (material.normalMap) {
                    material.normalMap.dispose();
                }
                if (material.specularMap) {
                    material.specularMap.dispose();
                }
                if (material.envMap) {
                    material.envMap.dispose();
                }
                material.dispose();
            }
        } else if (node.constructor.name === "Object3D") {
            node.parent.remove(node);
            node.parent = undefined;
        }
    }


Отлично, теперь возьмем все дополнительные классы, которые мы применяли, и дополним наш ResourceTracker:

dispose() {
    for (let key in sceneObject.afterimagePass) {
        this.disposeNode(sceneObject.afterimagePass[key]);
    }
    for (let key in sceneObject.bloomPass) {
        this.disposeNode(sceneObject.bloomPass[key]);
    }
    for (let key in sceneObject.composer) {
        this.disposeNode(sceneObject.composer[key]);
    }
}

Итоги

В результате всех этих действий я значительно повысил ФПС и уменьшил нагрузку GPU в своем приложении. Возможно, я некорректно применял ResourceTracker, однако он в любом случае не помог бы в работе с дополнительными классами. Про то, что перебор EffectComposer через наш disposeNode(node) влияет на число текстур, оказывающихся в памяти я нигде не видел (однако так оно и есть, судя по полученным результатам). Этот вопрос следует рассмотреть отдельно.

Для сравнения предыдущая версия проекта останется по старому адресу, а новую можно будет посмотреть отдельно.

Проект в некотором виде есть на гитхабе.

Буду рад услышать ваш опыт по работе с аналогичными проектами и обсудить детали! Мои контакты есть на сайте.


Тату Алексеевой

Jul-ya

Новый логотип ГлоАгента

Vizualle