Перейти к содержанию

Осмотр товара и управление камерой

Оглавление серии «Форма» · Урок 3 из 10.

Адаптивная сцена уже занимает доступную область, но показывает один ракурс. Теперь добавим осмотр лампы: пользователь сможет повернуть камеру вокруг предмета и приблизить изображение в заданных пределах. Сам предмет при этом останется в прежней системе координат. Ожидаемый результат — удобный осмотр без ухода камеры под условную поверхность стола и без возможности потерять товар далеко за пределами сцены. Кнопка вернёт исходный вид одним действием.

Для этого используем OrbitControls, дополнение из того же пакета Three.js 0.180.0. Дополнения импортируются отдельно, но устанавливать второй пакет с независимой версией не требуется. Это важное свойство структуры библиотеки: renderer, камера и управление происходят из одного закреплённого выпуска. Случайное подключение контроллера из другого CDN или старого файла усложняет совместимость и делает учебное состояние менее воспроизводимым.

Камера вращается вокруг цели

Контроллер получает камеру и DOM-элемент, на котором принимает действия указателя. В нашей странице это canvas renderer. События остальной формы ему не нужны. Если передать всю страницу, прокрутка и действия с полями могут начать влиять на осмотр предмета. Область управления должна совпадать с видимым местом взаимодействия: пользователь ожидает вращение после движения по изображению, а выбор цвета — после действия с соответствующим элементом формы.

controls.target задаёт центр осмотра. Это не положение камеры и не автоматический центр всех объектов сцены. В «Форме» установим цель на высоте 0,8, примерно посередине лампы. Если оставить цель в начале координат, вращение будет происходить вокруг нижней части основания. Предмет тогда чаще выходит из удобной композиции. Выбор цели относится к предметной задаче: для кольца, автомобиля и настольной лампы подходящая точка будет различаться.

Контроллер пересчитывает направление камеры относительно цели. После установки исходной позиции камеры и цели вызовем controls.update. Это согласует внутреннее состояние управления с выбранным ракурсом. Не стоит одновременно независимо управлять lookAt в каждом кадре и давать контроллеру менять направление: два механизма будут конкурировать. В первом уроке lookAt задавал статический вид, теперь постоянное управление этим направлением переходит к контроллеру.

Полное состояние осмотра

Ниже полный main.js третьего шага. Сцена, свет и размер области остаются прежними. Добавлены импорт контроллера, его настройки, обновление в кадровом цикле и обработчик кнопки. HTML-кнопка находится вне canvas и имеет обычную подпись. Возврат к исходному виду доступен даже человеку, которому неудобно самостоятельно доворачивать предмет жестом. Такая команда полезна после серии приближений и вращений.

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const host=document.querySelector('#viewport');
const onStatus=value=>document.querySelector('#status').textContent=value;
const onSelect=value=>{const selected=document.querySelector('#selected');if(selected)selected.textContent=`Выбрана деталь: ${value}`;};
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();}
model=new THREE.Group();
for(const [name,size,position,color] of [
 ['base',[1,.08,1],[0,.04,0],'#272532'],['stem',[.09,1.32,.09],[0,.74,0],'#777780'],['shade',[.75,.35,.6],[0,1.5,0],'#8044ca']]) {
 const part=new THREE.Mesh(new THREE.BoxGeometry(...size),new THREE.MeshStandardMaterial({color,roughness:.5}));
 part.name=name;part.position.set(...position);model.add(part);
}
scene.add(model);onStatus('Объёмное представление готово');
const ready=Promise.resolve();
function render(){if(closed)return;
controls.update();
renderer.render(scene,camera);frame=requestAnimationFrame(render);}
render();
document.querySelector('#reset').addEventListener('click',reset);

minDistance и maxDistance ограничивают расстояние камеры до цели. Минимальное значение два не даёт приблизиться так сильно, чтобы камера оказалась внутри плафона. Максимальное восемь не позволяет отдалиться до почти незаметного предмета. Эти ограничения не меняют масштаб модели и не являются границами ближней и дальней плоскости проекции. Расстояние осмотра и границы видимого пространства решают разные задачи, поэтому не заменяют друг друга.

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

Плавность требует обновления

Включим enableDamping. Контроллер сглаживает движение после непосредственного действия пользователя. Чтобы это сглаживание продолжалось, необходимо вызывать controls.update в кадровом цикле. Один вызов при создании не обслуживает дальнейшее затухание. Renderer по-прежнему рисует кадры, но контроллер должен вычислять актуальное положение камеры перед каждым изображением. Порядок здесь существенен: сначала новое положение, затем вывод сцены с этим положением.

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

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

Возврат должен восстановить весь ракурс

Функция reset возвращает позицию камеры и цель. Восстановление только позиции не гарантирует прежний кадр, если цель уже изменялась. Сейчас перенос отключён, но явное восстановление обеих величин делает контракт кнопки понятным. После изменений снова вызываем update. Если в будущем позволим менять тип камеры или режим проекции, команду возврата потребуется расширить: она должна восстанавливать действительно обещанное исходное представление.

Кнопка здесь не сбрасывает цвет, размер и другие параметры товара. Ракурс — состояние представления, комплектация — состояние самого выбранного продукта. Эти данные не следует незаметно смешивать. Пользователь, нажавший «Исходный ракурс», ожидает увидеть ту же лампу с привычной стороны. В последующих уроках настройки товара будут храниться отдельно, поэтому осмотр и выбор комплектации можно будет развивать независимо друг от друга.

Действия на мобильном экране

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

Для самостоятельного наблюдения попробуйте приблизиться до минимального расстояния, затем несколько раз отдалиться и нажать кнопку возврата. Ожидается, что лампа остаётся в зоне осмотра, а камера не проходит ниже поверхности. Затем уменьшите ширину контейнера: настройки предыдущего урока должны сохранить пропорции, а контроллер продолжит использовать ту же камеру. Если кнопка ничего не меняет, сначала проверьте её идентификатор и наличие обработчика, а затем положение и цель.

API управления описан в OrbitControls. Подключение дополнений показано в официальном разделе установки. Мы сохранили небольшую процедурную лампу, чтобы движение камеры было единственным новым механизмом. Следующий шаг заменит создание геометрии загрузкой собственного glTF. После этого станет особенно важно отличать готовность интерфейса от готовности модели: управление камерой может существовать раньше, чем закончится загрузка предмета.

Исходники этого шага

Архив серии содержит полную папку lesson-03. Возьмите именно её; переносить файлы из соседних папок не требуется. Версии и самостоятельная подготовка описаны в README. Пример, установка зависимостей и его выполнение при написании не запускались; результаты в тексте ожидаются по разбору исходников. Материал сохранён локально как Markdown-черновик.