Завершение сцены и освобождение ресурсов
Оглавление серии «Форма» · Урок 10 из 10.
Объёмное представление теперь создаётся по запросу. Но создать второй canvas проще, чем правильно завершить первый. В заключительном уроке добавим кнопку «Закрыть 3D» и опишем ресурсы, которыми владеет экземпляр viewer. Ожидаемый результат — закрытие сохраняет конфигурацию в обычной форме, останавливает кадры и позволяет позже открыть новое представление. Загрузка, закончившаяся после закрытия, не должна возвращать старую лампу на страницу или оставлять её ресурсы без владельца.
Главное различие здесь проходит между удалением DOM и завершением работы. Если убрать canvas, кадровая функция может продолжать вызываться. Если убрать mesh из сцены, его геометрия и материалы не обязательно освободят связанные GPU-ресурсы. Если отключить кадровый цикл, обработчики указателя и наблюдатель размеров всё ещё могут ссылаться на объекты. Поэтому завершение нужно рассматривать как отдельную последовательность действий над всеми созданными участниками, а не один метод удаления элемента.
Один экземпляр владеет своими ресурсами
В нашей лаборатории каждый viewer создаёт собственные renderer, controls, observer и модель. Между экземплярами нет общего кеша геометрии или материалов. Это позволяет освобождать их целиком при закрытии. Если в будущем появится общий кеш для десятков товаров, правила изменятся: закрытие одной карточки не должно уничтожать материал, используемый другой. Понятие владельца ресурса важнее механического обхода всех найденных объектов со случайным вызовом dispose.
Функция release собирает геометрии, материалы и текстуры в множества. Несколько mesh могут разделять один объект, поэтому множества предотвращают повторные действия с ним. Для материала проверяем как одиночное значение, так и массив. В собственном glTF материалов немного и текстур нет, но такой обход показывает разные типы ресурсов. Для сложного импортированного объекта потребуются дополнительные правила, например завершение декодеров, обработка ImageBitmap и освобождение собственных ресурсов расширений.
Ниже полный viewer.js заключительного состояния. В нём сохранена камера, загрузка модели, подбор ролей и применение конфигурации. Обратите внимание на closed: этот признак проверяется не только в функции закрытия, но и в кадровом цикле, а также после асинхронной загрузки. Он описывает состояние конкретного экземпляра. Номер поколения в main.js решает другую задачу — связывает экземпляр с текущим намерением интерфейса.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { palette, defaults } from './config.js';
export function createViewer(host, {onStatus,onSelect}, initial) {
const scene=new THREE.Scene();scene.background=new THREE.Color('#ece9f3');
const camera=new THREE.PerspectiveCamera(40,600/420,.1,30);camera.position.set(3,2.5,4);
camera.lookAt(0,.8,0);
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setSize(600,420,false);renderer.outputColorSpace=THREE.SRGBColorSpace;
host.append(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff,0x55506a,2));
const sun=new THREE.DirectionalLight(0xffffff,3);sun.position.set(3,5,2);scene.add(sun);
let model=null,frame=0,closed=false;
function resize() {
const {width,height}=host.getBoundingClientRect();if(width<=0 || height<=0)return;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1,2));
renderer.setSize(width,height,false);camera.aspect=width/height;camera.updateProjectionMatrix();
}
const observer=new ResizeObserver(resize);observer.observe(host);resize();
const controls=new OrbitControls(camera,renderer.domElement);
controls.target.set(0,.8,0);controls.minDistance=2;controls.maxDistance=8;
controls.maxPolarAngle=Math.PI*.49;controls.enablePan=false;controls.enableDamping=true;controls.update();
function reset() {camera.position.set(3,2.5,4);controls.target.set(0,.8,0);controls.update();}
let current={...(initial || defaults)};
function apply(config) {
current={...config};if(!model)return;
model.traverse(part=>{
if(!part.isMesh)return;
const role=part.userData.role || part.name;
if(role==='shade' || role==='base') part.material.color.set(palette[current[role]]);
if(role==='shade'){part.scale.x=.75*(current.size==='wide'?1.25:1);part.scale.z=.6*(current.size==='wide'?1.25:1);}
});
}
const raycaster=new THREE.Raycaster(),pointer=new THREE.Vector2();let down=null;
function pointerDown(event){down={x:event.clientX,y:event.clientY};}
function pick(event) {
if(!model || !down || Math.hypot(event.clientX-down.x,event.clientY-down.y)>5)return;
down=null;const rect=renderer.domElement.getBoundingClientRect();
pointer.set((event.clientX-rect.left)/rect.width*2-1,1-(event.clientY-rect.top)/rect.height*2);
raycaster.setFromCamera(pointer,camera);
const hit=raycaster.intersectObject(model,true).find(hit=>['shade','base'].includes(hit.object.userData.role || hit.object.name));
if(hit)onSelect(hit.object.userData.role || hit.object.name);
}
renderer.domElement.addEventListener('pointerdown',pointerDown);
renderer.domElement.addEventListener('pointerup',pick);
function release(root) {
const geometries=new Set(),materials=new Set(),textures=new Set();
root.traverse(object=>{if(!object.isMesh)return;geometries.add(object.geometry);
for(const material of (Array.isArray(object.material)?object.material:[object.material])) {
materials.add(material);for(const value of Object.values(material))if(value?.isTexture)textures.add(value);
}
});
for(const value of textures)value.dispose();for(const value of materials)value.dispose();for(const value of geometries)value.dispose();
}
function dispose() {
if(closed)return;closed=true;cancelAnimationFrame(frame);observer.disconnect();controls.dispose();
renderer.domElement.removeEventListener('pointerdown',pointerDown);renderer.domElement.removeEventListener('pointerup',pick);
if(model){scene.remove(model);release(model);model=null;}renderer.dispose();renderer.domElement.remove();
}
const ready=(async()=>{
const loaded=await new GLTFLoader().loadAsync('/assets/lamp.gltf');
if(closed){release(loaded.scene);return;}model=loaded.scene;scene.add(model);
apply(current);
onStatus('Объёмное представление готово');
})();
function render(){if(closed)return;
controls.update();
renderer.render(scene,camera);frame=requestAnimationFrame(render);}
render();
return {ready,apply,reset,dispose};
}
Порядок закрытия
Сначала проверяем, не был ли экземпляр уже закрыт. Повторный вызов должен быть безвредным: закрытие может прийти от кнопки и позднее от события ухода страницы. Затем устанавливаем closed, отменяем следующий animation frame, отключаем observer и controls. Если кадровая функция уже выполняется, признак не позволит ей запланировать полезную работу следующего прохода. Нельзя полагаться только на удаление canvas: очередь браузера и ссылки функций от этого не исчезают автоматически.
Удаляем собственные обработчики указателя. Для этого используем те же функции, которые передавались при подключении событий. Анонимную функцию, созданную заново внутри removeEventListener, браузер считал бы другим объектом. Контроллер имеет собственный dispose, освобождающий его слушатели. Это разные наборы событий: освобождение controls не удаляет автоматически наш обработчик выбора детали, потому что библиотека не владеет кодом, который мы подключили отдельно.
После этого убираем готовую модель из сцены и освобождаем её ресурсы. Renderer получает dispose, а canvas удаляется из DOM. Эти действия относятся к конкретному созданному экземпляру. Обычная форма, её обработчики и текущая конфигурация продолжают существовать. В интерфейсе закрытие не означает сброс товара: посетитель всё ещё может изменить размер или скопировать ссылку. При повторном открытии новая сцена получает актуальные параметры, сохранённые в форме.
Результат может прийти слишком поздно
Особенно интересен случай, когда пользователь нажал «Показать 3D» и сразу «Закрыть 3D». Импорт модуля или загрузка модели могли ещё не завершиться. В main.js увеличиваем поколение и освобождаем уже существующий экземпляр. После ожидания импорта функция сравнивает свой номер с текущим. Устаревший запрос не создаёт viewer. Если viewer уже создан, но его модель ещё загружается, признак closed относится именно к нему и будет проверен после loadAsync.
Когда поздняя модель пришла закрытому экземпляру, её нельзя просто проигнорировать как JavaScript-значение. Загрузчик уже создал геометрию и материалы. Вызываем release для полученной сцены и не добавляем её в общую сцену. Так поздний результат получает явное завершение. Проверка поколения не отменяет скачивание ресурса и не обещает отсутствие сетевого трафика после закрытия. Она ограничивает последствия этого трафика для интерфейса и владельцев ресурсов.
В данном примере загрузчик не обслуживает несколько конкурирующих моделей внутри одного viewer. Для такого варианта нужен ещё один номер запроса или собственная модель отмены. Если посетитель быстро меняет товар, результат старого запроса не должен заменить новый. Наша задача уже показывает основной принцип: асинхронный результат необходимо сопоставить с актуальным состоянием после ожидания, даже если все значения были правильными в момент начала операции.
Уход страницы и состояние формы
На pagehide интерфейс вызывает закрытие. Это полезная граница жизненного цикла страницы, но её нельзя использовать как единственный способ записать критические данные. Браузер может завершить процесс без удобного последнего события. В нашей форме конфигурация сохраняется после действий пользователя, как было показано в восьмом уроке. Поэтому закрытие отвечает за ресурсы представления и не несёт скрытую обязанность в последний момент спасать несохранённый выбор.
При восстановлении страницы из истории поведения могут зависеть от кеша браузера. Простая лаборатория закрывает viewer и предлагает снова включить его кнопкой. Для продукта можно отдельно обработать pageshow и восстановить желаемый режим. Но автоматическое создание должно по-прежнему следовать известной политике: если пользователь сознательно закрыл 3D, возвращение к странице не обязано навязывать его снова. Состояние желания видеть 3D можно хранить отдельно от комплектации товара.
Ресурсы и реальные измерения
Самостоятельно несколько раз откройте и закройте представление. Ожидается один canvas во время работы и отсутствие canvas после закрытия. Измените комплектацию между открытиями: новая лампа должна отражать последнюю форму. Затем попробуйте закрыть сразу после начала загрузки. Старый результат не должен появиться позднее. Серия при подготовке не выполнялась, поэтому такие наблюдения предлагаются читателю и не являются утверждением о проведённых измерениях памяти или кадров.
Методы освобождения описаны в руководстве Three.js о dispose. Счётчики renderer и инструменты браузера помогают исследовать ресурсы, но не следует ожидать мгновенного падения каждого числа до нуля: библиотека и браузер могут сохранять внутренние данные. Важна проверяемая политика владения и отсутствие накопления живых экземпляров после повторений. Мы завершили конфигуратор с независимой HTML-формой, ограниченным состоянием, собственным ресурсом модели и явным жизненным циклом. Дальнейшее подключение магазина потребует отдельного серверного договора заказа и измерений на реальных устройствах.
Исходники этого шага
Архив серии содержит полную папку lesson-10. Возьмите именно её; переносить файлы из соседних папок не требуется. Версии и самостоятельная подготовка описаны в README. Пример, установка зависимостей и его выполнение при написании не запускались; результаты в тексте ожидаются по разбору исходников. Материал сохранён локально как Markdown-черновик.