Размер области и плотность пикселей
Оглавление серии «Форма» · Урок 2 из 10.
В первом состоянии лампа выводилась на поверхности 600 на 420 пикселей. Но карточка товара может оказаться в широкой колонке, в мобильной странице или рядом с открытой боковой панелью. Реальная область изображения тогда отличается от исходного размера. В этом уроке соединим размеры DOM, перспективную проекцию и плотность пикселей экрана. Ожидаемый результат — лампа сохраняет пропорции при изменении ширины контейнера и остаётся достаточно чёткой на экране с высокой плотностью.
Важно различать три величины. Первая — размер контейнера в CSS-пикселях. Вторая — размер внутреннего буфера canvas, куда рисуется изображение. Третья — отношение сторон камеры. Если изменить только одну величину, изображение может растянуться, стать размытым или обрезаться неожиданным образом. Поэтому обновление размера удобно рассматривать как одну операцию над несколькими согласованными участниками, а не как случайный вызов метода renderer.
Контейнер определяет область
У #viewport в CSS задана ширина 100 процентов и конкретная высота. На узком экране высота уменьшается медиаправилом. Такая область существует до появления 3D и не зависит от размеров внутренних элементов. Это позволяет избежать цикла, при котором размер canvas меняет родительский элемент, наблюдатель снова измеряет родителя, затем canvas изменяется ещё раз. Сначала определяем место изображения, после этого подгоняем под него внутреннюю поверхность.
Для получения актуального прямоугольника используем getBoundingClientRect. Возвращённые ширина и высота могут быть дробными. Они выражены в CSS-пикселях, а не в физических точках экрана. В нашем контейнере нет внутренних отступов и рамки, поэтому прямоугольник подходит непосредственно. Если добавить такие стили, потребуется измерять именно доступную область содержимого. Не следует незаметно смешивать размер border-box с размером участка, занятого canvas.
Добавим ResizeObserver, наблюдающий за контейнером. Событие изменения размеров окна не охватывает все изменения интерфейса. Например, пользователь может открыть панель параметров, а окно браузера останется прежним. Наблюдатель контейнера учитывает такой случай, потому что связан с нужным элементом. Он не сообщает об изменении положения области без изменения её размеров; для текущей задачи положение понадобится позднее, при обработке указателя.
Полное состояние с изменением размера
Ниже полный модуль второго шага. Геометрия и свет сохраняются из предыдущего состояния, поэтому главный новый участок — функция resize и подключение наблюдателя. Перед измерением есть простая проверка ненулевых размеров. Она нужна для скрытого блока, ещё не открытой вкладки или временно схлопнувшегося контейнера. Делить ширину на нулевую высоту и передавать результат в камеру нельзя: такой промежуточный размер не описывает полезное изображение.
import * as THREE from 'three';
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();
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;
renderer.render(scene,camera);frame=requestAnimationFrame(render);}
render();
renderer.setSize(width, height, false) меняет размеры внутреннего изображения, сохраняя CSS-оформление canvas. Последний аргумент означает, что renderer не должен вписывать новый размер в CSS-стили поверхности. В нашем случае CSS уже задаёт стопроцентную ширину и высоту. Если позволить двум механизмам независимо менять оформление, станет труднее понять, кто управляет областью. Согласование размеров буфера с CSS остаётся обязанностью функции resize.
После изменения отношения camera.aspect вызываем updateProjectionMatrix. Значение поля камеры и матрица, используемая для проекции, являются разными частями состояния. Присваивание нового отношения сторон ещё не означает пересчёта всех производных данных. Без обновления матрицы камера продолжит использовать прежнюю проекцию, а renderer растянет её изображение на новую поверхность. Объект тогда выглядит шире или уже, хотя размеры его геометрии остались прежними.
Плотность и стоимость кадра
devicePixelRatio описывает отношение физических пикселей к CSS-пикселям в текущем контексте браузера. На экране с отношением два область шириной 600 CSS-пикселей может использовать буфер шириной 1200 пикселей. Площадь такого буфера растёт в четыре раза, потому что увеличиваются обе стороны. Это влияет на работу GPU и объём обрабатываемых данных. Нельзя считать повышение плотности бесплатным способом улучшить качество любого конфигуратора.
В примере ограничиваем отношение значением два. Это учебная политика качества: сохраняем достаточную чёткость, но не следуем безусловно очень высокому DPR устройства. Граница не является универсальной рекомендацией для любого продукта. На слабом телефоне может понадобиться меньшее значение, а при экспорте изображения требования будут другими. Здесь важно показать место, в котором такое решение принимается, и сохранить его согласованным для всех изменений размера.
Смена масштаба страницы или перенос окна между экранами способны изменить DPR. Наблюдатель размеров часто получит сопутствующее изменение CSS-прямоугольника, но полагаться на это для всех окружений нельзя. Наш вариант ориентирован на обычное изменение контейнера и получает DPR при каждом вызове resize. Для приложения, где перенос между мониторами является существенным сценарием, можно добавить отдельное наблюдение за медиавыражением разрешения и повторно вызвать ту же функцию.
Сохранение формы не равно одинаковому кадру
Когда контейнер становится узким, отношение сторон камеры уменьшается. Вертикальный угол обзора у перспективной камеры остаётся прежним, поэтому горизонтально видимая область меняется. Это корректная проекция, но предмет может оказаться тесно прижат к краям. Пропорции и композиция — разные задачи. Сейчас исправляем пропорции. Подбор расстояния по габаритам модели можно добавить отдельно, когда научимся загружать предметы с разными размерами.
Рассмотрим ожидаемое наблюдение. Если уменьшить ширину окна вдвое, основание лампы не должно стать овальным только из-за растяжения canvas. При этом в кадре может остаться меньше свободного места по бокам. При изменении высоты контейнера камера также пересчитывает отношение сторон. Если объект внезапно обрезается сверху, посмотрите на CSS-высоту, позицию камеры и её направление; не пытайтесь исправить это масштабированием самого предмета без понимания причины.
Наблюдение без скрытых измерений
Этот учебный пример не выполнялся при подготовке, поэтому здесь нет утверждения о частоте кадров или скорости конкретного устройства. Для самостоятельного наблюдения можно сравнить CSS-прямоугольник canvas и его атрибуты width и height в браузере. При DPR два внутренние размеры ожидаются примерно вдвое больше CSS-размеров. Само совпадение этих чисел ещё не доказывает хорошую производительность: оно только объясняет выбранную поверхность вывода.
Обратите внимание на скрытый контейнер. Если временно задать ему display: none, функция должна пропустить пересчёт для нулевого прямоугольника. После возвращения контейнера наблюдатель снова измерит размеры. Последний полезный кадр не требуется заменять кадром с неопределённой проекцией. Такой подход пригодится и при встраивании 3D в вкладки карточки товара. Полный контроль подключения и отключения наблюдателя рассмотрим в заключительном уроке серии.
Метод изменения размера описан в WebGLRenderer, а матрица проекции — в PerspectiveCamera. Поведение наблюдателя следует сверять с Resize Observer API. Разделение контейнера, буфера и проекции поможет сохранить понятный код, когда к лампе добавятся камера с управлением и новые элементы формы. Теперь у нас есть стабильная область, в которой можно позволить пользователю рассматривать предмет с разных сторон.
Исходники этого шага
Архив серии содержит полную папку lesson-02. Возьмите именно её; переносить файлы из соседних папок не требуется. Версии и самостоятельная подготовка описаны в README. Пример, установка зависимостей и его выполнение при написании не запускались; результаты в тексте ожидаются по разбору исходников. Материал сохранён локально как Markdown-черновик.