Как мы засунули геометрию квартиры в обычный PNG

В проптехе — продуктах для рынка недвижимости — рано или поздно появляется линейка: измерить комнату, проверить, пройдёт ли шкаф в проём, прикинуть высоту потолка. Если в браузере живёт 3D-сцена, задача почти учебная. У каждого объекта есть координаты, луч от курсора пересекает стену, и расстояние между двумя такими точками считается одной строкой.
У нас в браузере 3D-сцены нет, и это сознательное решение. Виртуальные туры Getfloorplan рендерятся в Unreal Engine с трассировкой пути (path tracing): мягкие тени, переотражённый свет, отражения в стекле и глянце. Кадр такого качества на рендер-ферме считается десятки минут на видеокарте уровня RTX — в браузере покупателя, да ещё на телефоне, это не повторить. Поэтому клиенту уходит не модель, а готовый кадр. По сути скриншот из игры: Unreal всё-таки игровой движок.
Тот же выбор даёт и скорость. Панорама весит от половины мегабайта до пары и приходит быстро. Сцену со всеми мешами, текстурами и картами нормалей пришлось бы грузить заметно дольше, а потом ещё и собирать её в видеопамяти.
Обратная сторона в том, что у картинки нет геометрии: курсору не во что попасть, лучу не с чем пересечься. Выглядит как компромисс — либо фотореалистичная картинка, либо линейка.
Привет! Меня зовут Руслан Мамлеев, я технический директор (CTO) в Getfloorplan. В прошлых статьях мы рассказывали, как собрали живой инвентарь инфраструктуры ИИ-агентами и как сотня клиентских форков виджета свернулась в один репозиторий с JSON Schema.
В этой статье — как мы из компромисса вышли и получили и то, и другое. Сначала математика: как по экранным координатам курсора найти точку в мире и нормаль к поверхности. Потом транспорт: почему браузер не принимает формат, в котором рождаются нужные нам данные, зачем мы положили float в цветовой PNG и что за это платим.
Примеры и замеры взяты из продакшена в сентябре 2026 года. Имена внутренних бинарей и хостов обезличены.
Как устроен тур
Партнёр — застройщик, классифайд, агентство — встраивает наш виджет к себе на сайт через iframe, и покупатель ходит по квартире, которой ещё нет.

Ферма из десятка боксов с RTX прогоняет Unreal и выплёвывает набор панорам и видов сверху. Они уезжают в объектное хранилище, а виджет их показывает. Панорама — одна равнопромежуточная (equirectangular) картинка, натянутая на сферу изнутри. В зависимости от качества рендера это 4096×2048 или 8192×4096. Ни мешей, ни рейкаста по стенам: построить линейку привычным способом здесь не на чем.
Идея: рядом с картинкой кладём вторую картинку
Если вместе с цветной панорамой отрендерить карту глубины, где в каждом пикселе лежит расстояние от камеры до видимой в нём поверхности, геометрия сцены больше не нужна. Она уже закодирована в картинке.

Карта глубины с подстроенным диапазоном отображения. Чем светлее — тем дальше.
Карта совпадает с цветной панорамой по ракурсу и развёртке: тот же кадр, только вместо цвета дистанция. Рендерится тем же прогоном движка, отдельным режимом. Разрешение у неё меньше: 2048×1024.

Слева направо: папка с текстурами сторон кубической карты, сферическая панорама, карта глубины.
Что рендерит движок
Режимы вывода передаются рендер-приложению в командной строке. Реальный вызов из живой сборки, подрезанный и обезличенный:
renderer.exe /Game/Maps/MainMovieRender -NOSOUND -RenderOffScreen \
-AssetPath .../plan.json -OutputDirectory .../output \
-PanoramasRenderModes '0|2|3' -SceneDepthResolution 2048 -PanoramasType 0 \
-TopDownViewCount 12 -MiddleCutHeight 150 -CameraIso 500
Важны два параметра:
-PanoramasRenderModes '0|2|3'— маска режимов:0цветное изображение,2карта глубины,3карта идентификаторов объектов. Комбинируются через|, на выходе рядом лежат файлы с суффиксами.-PanoramasType 0— сферическая панорама. Единица дала бы кубическую карту: шесть файлов сторон куба вместо одного.
Карта глубины уезжает в EXR. Дальше всё упирается в три свойства этого файла:
Значения в сантиметрах: одна единица равна одному сантиметру.
Глубина дублируется по каналам: в R, G и B лежит одно и то же число, так что два канала из трёх избыточны.
Формат — half-float. Это физический потолок продукта. К нему вернёмся дважды: он задаёт и максимальную дистанцию, и шаг хранения глубины.
Вот распределение значений глубины в одной реальной панораме:

Левая часть — сама комната: восемьдесят процентов пикселей лежат между 0,8 и 2,8 метра от камеры, медиана около 1,6 метра. А справа одинокий шпиль ровно на 655,04 метра — это небо в окнах, примерно десятая часть кадра. Дальние значения не размазаны облаком, а сошлись в одну точку. Почему так, разберём в разделе про множитель упаковки.
Часть первая. Математика: от курсора к точке в мире
Задача: пользователь двигает курсор по панораме, и в каждый момент мы должны знать, в какую точку трёхмерного мира он показывает и какая там нормаль к поверхности. Если это есть, линейка строится тривиально: цилиндр между двумя точками и подпись посередине.

Пользователь зажимает кнопку в первой точке, ведёт курсор, отпускает во второй. Если расстояние между началом и концом меньше порога (в демо-реализации 5 см), линейка не создаётся — это защита от случайного клика.
Есть и отдельное требование, которое ломает наивную реализацию «нажал-отпустил»: вид должен вращаться, пока тянут линейку. Иначе не измерить высоту потолка: пол и потолок не попадают в кадр одновременно.


Главная задача — получить из положения курсора точку в мире. Решается она за четыре шага.
Шаг 1. Экранные координаты → направление
Берём курсор в пикселях и депроецируем в мир, получая вектор World Direction.

В Unreal это готовая функция Deproject Screen to World, учитывающая угол обзора камеры и размер вьюпорта. В веб-фреймворке такой функции может не оказаться — тогда её разбирают на части: нормализованные координаты устройства, обратная матрица проекции.

Тонкость, на которой легко потерять день: на схеме стрелка идёт от камеры прямо до точки, но World Direction задаёт только направление, не расстояние. И его надо нормализовать до длины 1, иначе последующая тригонометрия поедет.
Шаг 2. Направление → координаты на панораме
Теперь надо понять, в какой пиксель карты глубины это направление попадает. У панорамы есть UV — аналог осей XY для текстуры.

Здесь нужно сразу зафиксировать соглашение: универсально правильной ориентации не существует, а от выбора зависит знак в формулах. Дальше вертикальная ось — Z, а V отсчитывается от верхнего полюса: взгляд строго вверх даёт V = 0, строго вниз — V = 1, горизонт — 0,5. Соответственно V растёт вниз по строкам текстуры, и нулевая строка отвечает взгляду вверх.

Для U нужны горизонтальные компоненты: панорама обёрнута вокруг вертикальной оси, и U растёт по кругу от 0 до 1.

Для V достаточно вертикальной компоненты:
U = (1.0 + atan2(WorldDirection.Y, WorldDirection.X) / Pi) / 2.0
V = acos(WorldDirection.Z) / Pi
Обе функции возвращают радианы, а не градусы.
При переносе в веб соглашение придётся собрать заново: в three.js вертикальная ось Y, а не Z, и ориентация загруженной текстуры может отличаться от той, в которой её записал движок. В нашем виджете формула та же, но вертикаль другая, и при индексации строки координата ещё раз переворачивается:
const centerU = 0.5 + Math.atan2(direction.z, direction.x) / (Math.PI * 2);
const centerV = Math.acos(direction.y) / Math.PI;
// ...
const pixelY = (height - v * height) | 0;
Само соглашение в данных нигде не записано: какой конец текстуры «верх», каждая реализация выясняет сама. Это первое из незаписанных соглашений, к которым мы вернёмся в конце.
Шаг 3. Глубина → точка
Читаем значение из карты по найденным координатам:
Point = CameraLocation + WorldDirection * SceneDepth
Положение камеры внутри панорамы обычно ноль координат. Всё, точка под курсором есть, линейка строится.
Казалось бы, можно расходиться. Но есть проблема с юзабилити.
Шаг 4. Искатель: почему одной точки мало
Если рисовать просто точку, пользователь не понимает, на какую плоскость навёлся: он видит плоскую картинку, а мерить собирается в пространстве. Решение — «искатель»: кружок, который лежит на поверхности и поворачивается вместе с ней.



Белый круг с точкой — искатель. Зелёная линия — нормаль к точке под курсором.
Чтобы знать нормаль, одной выборки не хватает: нормаль — это векторное произведение двух векторов, а для двух векторов нужно минимум три точки.

Берём центральную точку и все примыкающие — квадрат 3×3, девять значений. Размер задаётся параметром Extent: единица даёт 3×3, двойка 5×5.

Дальше точки соединяются в треугольники, для каждого считается нормаль, и всё усредняется.


Vector3 CalculateTriangleNormal(Vector3 Point1, Vector3 Point2, Vector3 Point3)
{
Vector3 V1 = (Point1 - Point2).Normalize();
Vector3 V2 = (Point1 - Point3).Normalize();
return V1 ^ V2;
}
С направлением нормали есть грабля. Векторное произведение может смотреть в любую сторону от плоскости, поэтому порядок обхода точек обязан быть постоянным. В Unreal система координат левосторонняя, треугольники собираются по часовой: 1, 2, 3 и 2, 4, 3. В правосторонней системе порядок надо развернуть, иначе часть нормалей будет смотреть внутрь стены.
Размер выборки — компромисс
Хочется сказать «больше точек — лучше нормаль», но это неверно.


На первой схеме Extent 1: нормаль уехала к верхней плоскости, хотя точка почти на ребре. На второй Extent 2: усреднение по большему числу точек описывает неровность мягче.
На гладкой поверхности больший Extent действительно снижает дрожание искателя. Но на настоящей границе объектов — торец шкафа, дверной косяк — выборка захватывает две разные поверхности с сильно разной глубиной, и усреднённая нормаль не соответствует ни одной из них. Она просто где-то между. Плюс большие выборки делают поворот искателя ленивым при движении курсора и стоят вычислений. Рабочий диапазон 1 или 2, и параметр стоит оставить настраиваемым.
Чтение патча, границы текстуры и восстановление направлений
Возня с краями карты. В реализации на Unreal выбран простой путь: проверяем центр на попадание в границы, дальше при необходимости уменьшаем Extent вплоть до нуля.
double[] ReadSceneDepthPixels(Texture SceneDepth, Vector2 Center, double Extent)
{
int CenterX = FMath::Clamp(Center.X, 0, TextureRenderTarget->SizeX - 1);
int MinX = FMath::Clamp(CenterX - Extent, 0, TextureRenderTarget->SizeX - 1);
int MaxX = FMath::Clamp(CenterX + Extent, 0, TextureRenderTarget->SizeX - 1);
int ExtentX = FMath::Min(FMath::Abs(CenterX - MinX), FMath::Abs(CenterX - MaxX));
int CenterY = FMath::Clamp(Center.Y, 0, TextureRenderTarget->SizeY - 1);
int MinY = FMath::Clamp(CenterY - Extent, 0, TextureRenderTarget->SizeY - 1);
int MaxY = FMath::Clamp(CenterY + Extent, 0, TextureRenderTarget->SizeY - 1);
int ExtentY = FMath::Min(FMath::Abs(CenterY - MinY), FMath::Abs(CenterY - MaxY));
int ClampedExtent = FMath::Min(ExtentX, ExtentY);
int X = CenterX - ClampedExtent;
int Y = CenterY - ClampedExtent;
int Range = 1 + ClampedExtent * 2;
FIntRect SampleRect(X, Y, X + Range, Y + Range); // Max не включён: [Min; Max)
return RenderTarget->ReadPixels(SampleRect);
}
Аккуратно переносить координаты через шов здесь не стали: в Unreal выборка идёт патчами, и каждое чтение патча тяжёлое, потому что данные лежат в памяти видеокарты. В вебе ситуация обратная — там читают попиксельно из обычного массива в оперативке, и вот там уже стоит сделать честный перенос через край, чтобы не обрабатывать частный случай «нормаль не построить, курсор на кромке». Если после схлопывания Extent осталось одно значение, вместо нормали берут обратный вектор направления на камеру: он меньше всего сбивает пользователя.
Направление известно только для центрального пикселя, для остальных его восстанавливают обратным ходом от формул U и V:
double UStepSize = 1.0 / SceneDepth.SizeX;
double VStepSize = 1.0 / SceneDepth.SizeY;
Vector3 GetTraceDirection(double U, double V)
{
Vector3 TraceDirection;
TraceDirection.X = cos((2.0 * U - 1.0) * Pi) * sin(V * Pi);
TraceDirection.Y = sin((2.0 * U - 1.0) * Pi) * sin(V * Pi);
TraceDirection.Z = cos(V * Pi);
return TraceDirection;
}
Массив читается линейно, поэтому индекс раскладывается на столбец и строку:
int SampleRectSize = FMath::Round(sqrt(DepthSamples.Num()));
int SampleRectCenter = SampleRectSize / 2;
int ColumnIndex = i % SampleRectSize;
int RowIndex = i / SampleRectSize;
double PixelU = U + UStepSize * (ColumnIndex - SampleRectCenter);
double PixelV = V + VStepSize * (RowIndex - SampleRectCenter);

Сырую нормаль использовать нельзя: искатель будет дёргаться на переходах между плоскостями. Она пропускается через покадровую интерполяцию к целевому значению:
Vector3 VInterpTo(Vector3 Current, Vector3 Target, double DeltaTime, double InterpSpeed)
{
if (InterpSpeed <= 0.0) return Target;
Vector3 Dist = Target - Current;
if (Dist.SizeSquared() < UE_KINDA_SMALL_NUMBER) return Target;
Vector3 DeltaMove = Dist * Clamp(DeltaTime * InterpSpeed, 0.0, 1.0);
return Current + DeltaMove;
}
Поворот искателя дальше считается через угол и векторное произведение между сглаженной нормалью и вектором вверх.
Перекрытие мебелью: целиком в шейдере
Без одной детали линейка так и осталась бы демкой: она должна честно уходить за предметы. Мерим от дальней стены, на пути шкаф — за шкафом линия становится полупрозрачной.

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

Чёрно-белая маска перекрытия по карте глубины, дальше используется как карта прозрачности.
Формулы те же, с точностью до порядка аргументов и инверсии вертикали. Поворот развёртки на 90 градусов, если нужен, делается прибавлением 0.25 к U — потому что 90 из 360 это ровно четверть, и матрица для этого не нужна.
Что известно про точность
У точности линейки несколько ограничений, и они очень разного размера.
1. Соответствие модели настоящей квартире. Самый крупный источник погрешности, и карта глубины тут вообще ни при чём. Сцена построена по чертежу планировки, а не по обмерам реального помещения. Линейка меряет модель. Насколько модель совпадает с квартирой, которую построит застройщик, — отдельный вопрос, к нашему пайплайну отношения не имеющий. Инструмент позволяет предварительно оценить расстановку мебели, но для приёмки после ремонта он не предназначен.
2. Шаг хранения глубины: half-float. У half-float десять бит мантиссы, поэтому шаг между соседними представимыми значениями растёт вместе с величиной. Значения у нас в сантиметрах, и границы смены шага приходятся на степени двойки: 0,64 м, 1,28 м, 2,56 м, 5,12 м и так далее.

На полуметре шаг 0,31 мм, на метре 0,62 мм, на двух метрах 1,25 мм, на пяти 2,5 мм, на десяти 5 мм. Никакая последующая упаковка потерянное здесь не возвращает.
3. Квантование при упаковке в PNG. О нём вся вторая половина статьи. В выбранном режиме шаг упаковки 0,1 мм — в диапазоне 0,5–5 м это мельче шага хранения, хотя на ближнем краю разница всего троекратная.
4. Попадание курсора в нужную поверхность. Практически самое коварное после первого пункта. Карта глубины отрендерена в меньшем разрешении, чем цветная панорама, и на границе стены и шкафа соседний пиксель даёт совсем другую глубину. Промах курсором на пиксель у ребра — это ошибка не в миллиметрах, а в десятках сантиметров. Отсюда, собственно, и весь искатель с усреднением нормали: он существует, чтобы пользователь видел, куда именно попал.
Если свести вместе второй и третий пункты, на дистанциях 1–5 метров шаг хранения глубины составляет примерно 0,6–2,5 мм, а упаковка добавляет более мелкое округление в 0,1 мм. Это характеристики представления данных, и только их. Погрешность готовой линейки — с двумя конечными точками, двумя восстановленными направлениями, выборкой из текстуры и поведением на ребре — мы отдельно не измеряли.
Как закрыть этот вопрос, понятно: взять десяток известных расстояний из исходного чертежа, промерить их линейкой в туре и показать разброс. Этого мы ещё не сделали.
Отсюда же видно, где кончается обычное оправдание меньшего разрешения карты — «мерить расстояния можно грубее, чем разглядывать обои». Внутри однородной поверхности это верно, а на ребре объекта неверно: именно там соседний пиксель отличается на десятки сантиметров.
Часть вторая. А теперь довезите это до браузера
Всё, что выше, работает в нативном приложении на Windows: у нас есть внутренний вьюер, где эта линейка живёт с 2024 года.


Но продукт у нас — iframe на чужом сайте. С этого места задачи становятся менее академическими.
EXR — не веб-формат
Карта глубины лежит в EXR — индустриальном стандарте для HDR и правильном формате для float-данных. Только браузер про него не знает ничего.
$ curl -I .../Panorama_..._SceneDepth.exr
HTTP/2 200
content-type: binary/octet-stream
content-length: 574379
binary/octet-stream. Для браузера это просто мешок байт: ни в <img>, ни в drawImage его не отдать; декодировать его средствами платформы тоже нельзя. Значит, тянем файл через fetch, получаем arrayBuffer, парсим EXR на JavaScript (у нас EXRLoader из three.js) и получаем Float32Array.
Работает, но вот цена (100 файлов, три прогона, одна машина):

Границы замера важны, иначе цифру легко переоценить. Со стороны PNG в 34 мс входит весь путь до готовых байт: загрузка через <img>, drawImage на canvas и getImageData. Со стороны EXR в 241 мс входит загрузка и полный разбор в JS в главном потоке, без воркера. Настоящей сети нет ни там, ни там: файлы отдаются локально. PNG под замером переупакован через canvas.toBlob, то есть это не побайтово тот файл, что кладёт пайплайн. И распаковка 24 бит в сантиметры в замер не входит — она происходит позже, при чтении пикселя, тогда как EXR в распаковке не нуждается вовсе.
С этими оговорками порядок величины устойчив: разница примерно семикратная. Парсинг оказался настолько тяжёлым, что в виджете его увели из главного потока в web worker — иначе на переходе между панорамами интерфейс подвисал.
Загрузчик EXR с воркером
private async parse(buffer: ArrayBuffer): Promise<TDepthData> {
if (window.Worker) {
return new Promise<TDepthData>((resolve, reject) => {
const worker = new ExrLoaderWorker(buffer);
worker.onmessage = (e: { data: TWorkerData }) => {
if (e.data.isError) reject(e.data.value);
else resolve(this.serializeData(e.data.value));
};
worker.postMessage(buffer);
});
}
const loader = new EXRLoader();
loader.type = FloatType;
return this.serializeData(loader.parse(buffer));
}
Заодно это объясняет, почему в панели Network карта глубины ведёт себя непохоже на остальные ассеты. Цветная панорама — это <img>: браузер сам её скачивает, кэширует и декодирует нативным кодом. А карта глубины — fetch с собственным слоем кэша поверх Cache API:
let response = await CacheHandler.get(url);
if (!response) {
response = await fetch(url, { method: 'GET', cache: 'no-cache' });
await CacheHandler.put(url, response);
}
buffer = await response.arrayBuffer();
Сам браузер бинарные ответы кэширует прекрасно: понимать содержимое ему для этого не требуется. И cache: 'no-cache' кэш не отключает: этот режим требует проверять актуальность перед повторным использованием.
Свой кэш понадобился по двум причинам. Первая: раздача отдаёт карты с cache-control: max-age=300, то есть пять минут, а сессия просмотра тура длится дольше — поэтому CacheHandler держит свой TTL в тридцать минут, вычисляя срок из заголовков expires и date. Вторая, более весомая: рядом живёт отдельное хранилище в IndexedDB, куда складывается уже разобранный результат. Кэшировать имеет смысл именно его, иначе те 241 миллисекунду разбора пришлось бы повторять при каждом возврате на панораму.
PNG-хак: 24 бита в три цветовых канала
PNG браузер декодирует нативно и быстро. В нём три канала по 8 бит, то есть 24 бита. А глубина нам нужна не с точностью float, а «до миллиметра в пределах квартиры».
Значит, можно взять число, умножить на постоянный множитель, чтобы дробная часть уехала в целую, и разложить результат по трём каналам. Картинка получается психоделическая, но показывать её никто и не собирается: её только читают.

Вот конвертер, работающий в проде — тот самый бинарь, который вызывается на рендер-боксах:
const pr = 10 ** precision;
if (isRaw) {
for (let i = 0; i < dataLength; i += 4) {
outData[i] = data[i];
outData[i + 1] = data[i + 1];
outData[i + 2] = data[i + 2];
outData[i + 3] = 0xff;
}
} else {
for (let i = 0; i < dataLength; i += 4) {
const value = Math.min(0xffffff, Math.round(data[i] * pr));
outData[i] = (value >> 16) & 0xff;
outData[i + 1] = (value >> 8) & 0xff;
outData[i + 2] = value & 0xff;
outData[i + 3] = 0xff;
}
}
Обратите внимание на Math.min(0xffffff, ...): кламп по 24 битам обязателен. Без него значение 16 777 216 после маски & 0xff дало бы не максимум, а ноль — бесконечно далёкая точка притворилась бы точкой в самой камере.
В том же репозитории рядом живёт демо-реализация этого же алгоритма, и в ней клампа нет:
// демо-версия из того же репозитория
const v = Math.round(data[i] * pr);
outData[i] = (v >> 16) & 0xff;
В рабочем диапазоне значений обе версии дают одинаковый результат, при переполнении расходятся. Поэтому ручная проверка через демку не полностью воспроизводит поведение продакшена, и знать об этом стоит заранее.
Второй режим, --raw, обслуживает карты идентификаторов комнат и объектов — по ним виджет понимает, на что навёл курсор, и подсвечивает конкретный шкаф. Там значения целые и мелкие, поэтому просто переносим младший байт.
# глубина: 24-битная упаковка с множителем
e2p --precision 2 .../Panorama_..._SceneDepth.exr
# идентификаторы: прямой перенос цвета
e2p --raw .../Panorama_..._RoomIDs.exr
На стороне виджета работа с готовым PNG сводится к тому, ради чего всё затевалось: браузер декодирует сам, нам остаётся забрать байты.
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const context = canvas.getContext('2d')!;
context.drawImage(img, 0, 0);
const result = context.getImageData(0, 0, canvas.width, canvas.height);
resolve(this.serializeData(result, precision));
};
А распаковка живёт в контроллере рейкаста, там же, где ищется точка под курсором:
private getDepthValue(u: number, v: number, depthMap: TDepthData) {
const { width, height } = depthMap;
const pixelX = (u * width) | 0;
const pixelY = (height - v * height) | 0;
const index = (pixelY - 1) * width * 4 + pixelX * 4;
const [r, g, b] = depthMap.data.slice(index, index + 3);
let precision = depthMap.precision;
if (depthMap.format === 'png' && precision === -1) precision = 2;
if (precision === -1) return r; // EXR: во float уже сантиметры
return ((r << 16) | (g << 8) | b) / 10 ** precision;
}
precision === -1 для EXR означает «не распаковывай»: во float уже лежат готовые сантиметры, распаковывать нечего. Такой же костыль стоит для PNG на случай записей из старого клиентского кэша, где точность не сохранялась.
В этих четырёх строках индексации не хватает проверок диапазона. При u = 1 получается столбец за правой границей. При v = 1 индекс уходит в минус, и slice на типизированном массиве вместо ошибки молча отсчитает от конца буфера — то есть вернёт значения с другого края картинки. В обычном сценарии курсор в такие координаты не попадает, но допустимый диапазон и обработка шва и полюсов здесь нигде не описаны.
Сколько мы сэкономили в байтах
Ноль. Мы проиграли.
Для сравнения взяли одну и ту же панораму и сложили её карту глубины в разные контейнеры. EXR — исходник из хранилища, и он уже сжат: в заголовке файла стоит compression = 3, то есть ZIP. PNG собран из него тем же алгоритмом упаковки, что работает в проде, и кодировщиком с теми же настройками — размер совпадает с боевыми файлами в пределах процента. Массив чисел — те же значения глубины, сжатые штатными средствами:

PNG вышел в 1,8 раза тяжелее исходного EXR: около мегабайта против 574 килобайт. Карта глубины в PNG весит почти вдвое больше, чем цветная панорама той же точки съёмки. А если развернуть карту в обычный массив float16 и сжать тем, что браузер распаковывает нативно, получается 767 килобайт под gzip и 376 под brotli — в 2,7 раза меньше PNG.
На второй панораме соотношение того же порядка: 667 килобайт у боевого PNG против 275 у массива под brotli. Это два измеренных кадра; насколько картина сохраняется на всём наборе планировок, мы не проверяли.
Так что переезжали на PNG не ради размера: по размеру он проиграл обоим вариантам, с которыми его можно сравнить.
Почему тогда PNG
Обычный сжатый массив легче, но собственный бинарный формат со сжатием мы тогда отдельно не оценивали. PNG позволял использовать уже готовый загрузчик изображений: браузер сам скачивает файл и отдаёт декодированные пиксели. Для бинарного массива пришлось бы определить формат данных — порядок байт, тип значений, размеры — и встроить его чтение в виджет. Раздача, CDN и HTTP-кэш при этом остались бы теми же: бинарные файлы они обслуживают так же, как картинки.
То есть выбор делался по объёму новой работы на клиенте, а размер при этом не измеряли.
Выходит, дорого браузеру обходится разбор конкретного контейнера, в котором эти числа у нас уже лежали, то есть EXR. Сами числа в виде массива он прекрасно примет, а gzip и brotli распакует нативно, без единой строки нашего кода.
Чем мы заплатили за PNG
Счёт из трёх пунктов.
1. Чтение пикселей требует корректного CORS
Чтобы прочитать пиксели, картинку надо нарисовать на canvas и позвать getImageData. И тут два разных сценария, которые легко перепутать.
Если картинку загрузить без crossOrigin, она с чужого домена прекрасно отобразится, но canvas, на который её нарисовали, станет «испорченным» (tainted), и getImageData бросит SecurityError. Показать можно, прочитать нельзя.
Если поставить crossOrigin = 'anonymous', как у нас, требование переезжает на этап загрузки: сервер обязан разрешить кросс-доменный доступ, иначе изображение просто не загрузится и сработает onerror. До рисования дело не дойдёт.
$ curl -I -H 'Origin: https://getfloorplan.com' .../..._SceneDepth.png
HTTP/2 200
content-type: image/png
access-control-allow-origin: https://getfloorplan.com
access-control-allow-methods: GET, HEAD, OPTIONS
vary: Origin, Accept-Encoding
В обработчике onerror у нас стоит переход к фолбэку, так что ошибка в заголовках раздачи не уронит виджет с исключением — она тихо уведёт загрузку на другой путь или оставит тур без линейки. Отлаживать такое неприятнее, чем явное исключение.
Главное в этом ответе — последняя строка: Vary: Origin означает, что CDN кэширует ответ раздельно для каждого origin. Без него кэш отдал бы случайному клиенту ответ с чужим access-control-allow-origin, и линейка отваливалась бы у части партнёров через раз. Мы такое ловили на другом эндпоинте и лечили ровно добавлением origin в ключ кэша, так что цена ошибки известна на практике.
При отладке curl без заголовка Origin про это соврёт. Он покажет прекрасный ответ, которого браузер никогда не увидит.
2. Цвет должен остаться нетронутым по всей цепочке
Это самая коварная часть, но преувеличивать риск не стоит.
PNG умеет носить чанки gAMA, sRGB, iCCP — гамму и цветовой профиль. Если браузер применит цветовое преобразование при декодировании, картинка с числами тихо испортится: измерения поедут, а поскольку ничего не упадёт, никто этого не заметит.
Но само наличие тега ещё ничего не ломает. Canvas по умолчанию работает в sRGB, и изображение без профиля он тоже интерпретирует как sRGB — то есть тег, совпадающий с рабочим пространством, преобразования не вызывает. Опасно фактическое преобразование между разными пространствами: профиль iCCP, отличный от sRGB, или гамма, которую движок решит применить.
Боевой файл по чанкам:
IHDR 2048x1024 bitdepth=8 colortype=2 interlace=0
chunks: [('IHDR', 13), ('IEND', 0)] IDAT: 22 штуки
has gAMA: False has sRGB: False has iCCP: False
Чисто: только заголовок, данные и конец файла. colortype 2 — истинный цвет без альфы: конвертер пишет альфу в 0xFF, а кодировщик её выбрасывает как бесполезную.
Вывод отсюда скучнее, чем «профилей нет, значит всё хорошо»: нужный нам инвариант — побайтовая сохранность на всём пути «кодировщик → браузер → canvas → распаковка». Отсутствие профилей лишь одно из условий и само по себе корректности не доказывает. Проверяется это круговым тестом: закодировать известный набор значений, прочитать обратно через настоящий getImageData, сравнить. Такого теста у нас нет.
3. Множитель упаковки задаёт потолок дальности
Упаковка вмещает 16 777 215. Множитель 10^precision определяет и шаг квантования, и максимум, который влезает: прямой обмен одного на другое.

Здесь же разгадка шпиля с гистограммы из начала: почему он стоит ровно на 655 метрах и почему это именно шпиль.
Максимальное значение в карте — 65 504 сантиметра, в упакованном PNG это 6 550 400. А 65 504 — максимальное конечное значение half-float. Небо и всё, что дальше, вплоть до бесконечности, схлопывается в потолок формата ещё внутри движка, задолго до нашей упаковки. Поэтому на гистограмме и стоит одно значение.
При precision = 2, то есть множителе 100, в 24 бита влезает 1678 метров — запас над потолком данных в два с половиной раза, и кламп в проде не срабатывает. При precision = 3 предел упал бы до 168 метров, и все виды из окна схлопнулись бы в максимум — причём в самых дорогих квартирах с самыми красивыми видами.
При этом двойка не единственное рабочее значение: единица тоже работает, у неё шаг 1 мм и запас в двадцать пять раз, а шаг хранения дальше 1,3 метра всё равно крупнее миллиметра. Двойка лучше тем, что в рассматриваемом диапазоне 0,5–5 м шаг упаковки при ней меньше шага хранения глубины, а «точность 0,1 мм» сама по себе здесь не аргумент. Выбор хороший, но в документации он не зафиксирован никак и восстанавливается только обратной арифметикой.
Множитель, который живёт в трёх местах
Эта часть скорее для тех, кто систему эксплуатирует.
precision — значение, от которого зависит корректность всех измерений. Ошибка в нём на единицу — ошибка в измерениях в десять раз.

Хранится оно одновременно в трёх местах на трёх разных языках, и эти места друг о друге не знают:
Где | Что стоит | Язык |
|---|---|---|
| параметр по умолчанию | PowerShell |
Ресурс API бэкенда | константа | PHP |
Загрузчик виджета | дефолт параметра | TypeScript |
Причём пайплайн вызывает скрипт без флага, полагаясь на дефолт PowerShell:
powershell label: 'Creating copies of .exr in .png', script: """
if ("${exr_to_png}" -eq "true") {
$WORKSPACE/e2p/run.ps1 -path $WORKSPACE/output
} else {
Write-Host "Feature disabled via param exr_to_png. To enable, pass 'true'"
}
"""
А сама утилита, если позвать её вручную без флага, возьмёт собственный дефолт — единицу. Для CLI это разумный дефолт, и автор конвертера не обязан был знать про константу в PHP.
Сегодня это не баг: распределение значений в боевом PNG соответствует множителю 100, продукт считает правильно. Но конструкция не умеет сообщить о своей поломке. Поменяйте дефолт в run.ps1 на единицу, и файлы начнут писаться с множителем 10, а читаться с множителем 100 — то есть все расстояния станут в десять раз меньше. Комната шириной три метра покажется тридцатью сантиметрами. Ничего не упадёт, и ни Sentry, ни тесты этого не заметят.
Рядом стоит удаление исходника. После конвертации EXR стирается на месте, до выгрузки в хранилище:
foreach ($file in $exrFiles) {
if ($file.Name -like "*_SceneDepth.exr") {
& $e2pExe --precision $precision $file.FullName
}
Remove-Item -Path $file.FullName -Force
}
Удаление необратимое, но из ошибки множителя перерендер не следует: если фактический множитель известен и переполнения не было, масштаб чинится константой при чтении или перекодированием готовых PNG. Настоящая цена удалённого исходника в другом: без EXR не восстановить то, что потеряно при округлении, переполнении или ошибке самой конвертации.
Хвост, который тянется больше года
Конвертация выключена по умолчанию. Параметр exr_to_png в пайплайне имеет defaultValue: 'false', и PNG появляется, только если бэкенд явно передал true. Бэкенд включает флаг, когда в опциях планировки указан формат PNG, а если формат не указан, считает его EXR. Без флага карты уезжают в хранилище как есть, в EXR, — так их и получили все планировки, отрендеренные до перехода.
Из-за этого в проде живёт фолбэк. Виджет запрашивает .png, не нашёл — пробует .exr:
let result = await loader.load(url, precision);
if (result == null) {
result = await loader.load(url.replace('.png', '.exr'), precision);
}
Фолбэк односторонний, и это правильно: EXR-загрузчик всегда возвращает precision = -1, то есть сырые float, поэтому измерения не портятся, даже когда бэкенд прислал двойку.
Но каждое срабатывание пишет console.error, а он улетает в Sentry:
Fallback png to exr https://cdn.../projects/202506/.../TopView_0_0_RoomIDs.png
Эта ошибка висит в мониторинге больше года. Каждый заход в старый тур аккуратно капает туда «ошибкой», хотя ничего не сломано. Никто не чинит, потому что работает.
Кстати, посмотрите на имя файла в этом сообщении: RoomIDs. Ошибка прилетает не от линейки, а от карт идентификаторов — того самого второго режима конвертера.
Сама миграция формата растянулась больше чем на полтора года.

Конвертер написан в ноябре 2024-го, поддержка PNG в виджете появилась в марте 2025-го, к июлю 2025-го в PNG шло уже больше трети карт глубины, EXR перестал производиться в июле 2026-го. Двадцать месяцев, и шла миграция не монотонно: в ноябре 2025-го и январе 2026-го большая часть карт глубины снова уезжала в EXR. Всё это время в коде обязаны были жить оба пути. Причём «в проде один формат» сказать нельзя до сих пор: новые файлы переведены на PNG, а чтение старых EXR осталось — и останется, пока живы туры, отрендеренные до миграции.
Масштаб стоит считать аккуратно.

Здесь легко смешать две разные вещи. Сам конвертер работает на половине парка: карты идентификаторов комнат и объектов есть примерно у каждой второй планировки, и они обслуживают подсветку объектов, а не измерения. А карта глубины, ради которой существует вся математика первой части, включается точечно под конкретных партнёров: это меньше половины процента планировок. Например, одна из сентябрьских сборок на ферме шла с режимами '0|3' — цвет и идентификаторы, без глубины.
То есть инфраструктура конвертации оправдана широким применением, а линейка — узкое премиальное дополнение поверх неё.
Что стоит сделать иначе
Множитель должен ехать вместе с данными. Самый дешёвый вариант — положить его в имя файла: ..._SceneDepth_p2.png. Само переименование, впрочем, ничего не гарантирует: помогает оно только если загрузчик этот параметр читает и проверяет, а при несовпадении отказывается считать, вместо того чтобы молча взять свой дефолт. Текстовый чанк tEXt внутри PNG выглядит элегантнее, но путь Image → canvas → getImageData возвращает только пиксели, никаких текстовых чанков, так что за метаданными придётся отдельно тянуть файл и парсить заголовок.
Круговой тест на сохранность байт. Тот самый, из раздела про цвет: закодировать известный набор значений, прочитать обратно через настоящий getImageData, сравнить. Такой тест поймает цветовые профили, смену версии кодировщика и расхождение двух реализаций конвертера.
Исходник стоит оставлять. Хотя бы одну панораму на планировку и хотя бы на месяц: иначе потерянное при округлении не восстановить.
Фолбэк должен молчать, когда он ожидаем. Ошибка, которая срабатывает на каждом заходе в старый тур, — это шум, который приучает не смотреть в Sentry.
Диапазоны при индексации. Явно описать допустимый диапазон u и v, обработку шва и полюсов. Сейчас на границе тихо читаются значения с другого края картинки.
Погрешность инструмента надо измерить. Бюджет, выведенный из устройства формата, замер не заменяет: нужен прогон по известным расстояниям из чертежа.
Попробовать самим
Линейку можно проверить в демо-туре из галереи на нашем сайте — загородный дом Farmhouse. Панорамы там 8192×4096 с пресетом качества Ultra, а карты глубины приходят в PNG, по тому самому пути из второй части. Линейка включается кнопкой в нижней панели, каждые два щелчка по панораме дают отдельный отрезок.

Два отрезка: по двери холодильника — 1,83 м, вдоль столешницы острова — 2,61 м. Метки точек легли по нормали к поверхности: на двери они стоят вертикально, на столешнице лежат плашмя. Оранжевый овал на полу — искатель под курсором.
Итог
Компромисс из начала статьи — либо фотореалистичная картинка, либо линейка — оказался ложным. Картинку мы оставили как есть и положили рядом геометрию, которой в ней нет: во вторую картинку, где вместо цвета лежит расстояние. Всё остальное — депроекция курсора, развёртка сферы, нормаль по патчу, перекрытие в шейдере — просто аккуратная работа с этими двумя картинками.
За PNG мы заплатили более тяжёлыми файлами. Но менее очевидная цена — соглашение о том, как читать данные, которое в самой картинке не записано. Чтобы восстановить расстояния, загрузчик должен заранее знать множитель упаковки и ориентацию карты: картинка перестала быть самодостаточной.
И это соглашение размножилось. Множитель упаковки живёт в трёх системах на трёх языках, направление вертикальной оси в двух реализациях задано по-разному и в данных не зафиксировано. А сохранность RGB-байтов при декодировании остаётся требованием, которое пока не проверяется автоматическим тестом.
Отсюда практический вывод, который мы для себя сделали: если данные упакованы в формат, придуманный для чего-то другого, то соглашение о том, как их читать, — такая же часть системы, как сам код. Его нужно записать рядом с данными и проверять автоматически: множитель в метаданных, которые загрузчик сверяет, круговой тест на байты в CI конвертера, явно зафиксированная ориентация осей. Пока этого нет, корректность держится на том, что все участники угадали одинаково.
Если вы ловили похожие ловушки при упаковке данных в изображения, расскажите в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.