Живая карта глубины в Unreal Engine — основа интерактивных инсталляций, виртуального продакшена и цифровых двойников стендов: материал по глубине подсвечивает силуэт посетителя, режет стены, управляет частицами. Femto Bolt (преемник Azure Kinect DK — о переезде с него мы писали отдельно) идеально ложится в эту роль: iToF-камера с диапазоном 0,1–5,5 м и RGB 4K, один USB-кабель, стабильный SDK. Задача гайда — получить в UE5 текстуру глубины, которую можно подключить в любой материал. Архитектура: лёгкий Python-сайдкар забирает depth через pyorbbecsdk, маппит миллиметры в 8 бит и шлёт кадр по UDP на localhost; C++-актор в UE принимает его в фоновом потоке и обновляет UTexture2D на игровом треде. Такое разделение держит SDK-зависимости вне сборки движка и легко отлаживается: каждую половину проверяете отдельно.
По той же схеме движутся и другие данные: цветной кадр можно гнать вторым потоком (тот же UDP, другой порт, формат bgr24), а из depth-картинки в UE удобно считать не только силуэт, но и «объём» посетителя — по перепаду байтов внутри контура. Если проекту нужны скелеты, к их трекингу лучше идти отдельным процессом поверх RGB; для инсталляций же, где важна живость реакции, канал глубины часто работает в одиночку — и этого достаточно для убедительного эффекта.
Что понадобится
- камера Orbbec Femto Bolt и хост на Windows с портом USB 3.0;
- Unreal Engine 5.x с установленным Visual Studio (сборка C++-проекта);
- Python 3.8+ с pyorbbecsdk, NumPy (сайдкар);
- 30 минут: половина уйдёт на первый запуск обеих сторон.
Пошаговая настройка
Шаг 1. Python-сайдкар: берём глубину и шлём кадр
Сайдкар открывает depth-поток, приводит глубину к 8-битной шкале по рабочему диапазону камеры и отправляет кадр вместе с заголовком из двух int32 (ширина, высота):
import socket
import struct
import numpy as np
from pyorbbecsdk import Pipeline, Config, OBStreamType
D_MIN, D_MAX = 0.1, 5.5 # рабочий диапазон Femto Bolt, метры
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
pipeline = Pipeline()
config = Config()
config.enable_stream(OBStreamType.OB_STREAM_DEPTH)
pipeline.start(config)
while True:
frames = pipeline.wait_for_frames(100)
if frames is None:
continue
d = frames.get_depth_frame()
if d is None:
continue
h, w = d.get_height(), d.get_width()
depth = np.asarray(d.get_data(), dtype=np.uint16).reshape(h, w)
meters = depth.astype(np.float32) / 1000.0
img = np.zeros_like(meters)
m = (meters >= D_MIN) & (meters <= D_MAX)
img[m] = (meters[m] - D_MIN) / (D_MAX - D_MIN)
g8 = (img * 255.0).astype(np.uint8)
payload = struct.pack("<ii", w, h) + g8.tobytes()
sock.sendto(payload, ("127.0.0.1", 7777))
Запустите и убедитесь, что трафик идёт (в диспетчере задач виден питон с загрузкой сети). Разрешение потока можно понизить профильом в Config — для материала в UE полные 1280×1024 редко нужны, а трафик и CPU падают кратно.
Какой профиль выбирать: для инсталляции с одним посетителем 320×288 часто выглядит неотличимо от полного разрешения, потому что материал всё равно размывает шкалу. Отталкивайтесь от физического размера зоны на экране: если силуэт занимает четверть видеостены, полное разрешение не окупится. Ещё один аргумент в пользу меньшего профиля — сайдкар почти ничего не ест, и вся машина остаётся движку и рендеру, что для интерактивных сцен важнее лишних пикселей глубины.
Шаг 2. Актор-приёмник и текстура
В C++-проекте UE создайте актор ADepthReceiver. В BeginPlay создаём целевую текстуру в формате PF_G8 (один байт на пиксель — ровно то, что шлёт сайдкар) и открываем UDP-сокет:
// DepthReceiver.h (сокращённо; сверьте с API вашей версии UE)
#pragma once
#include "GameFramework/Actor.h"
#include "Sockets.h"
#include <atomic>
#include <thread>
#include "DepthReceiver.generated.h"
UCLASS()
class ADepthReceiver : public AActor
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadOnly)
UTexture2D* DepthTexture;
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type Reason) override;
virtual void Tick(float Dt) override;
private:
void ListenLoop();
FSocket* Socket = nullptr;
std::atomic<bool> bRunning{false};
std::thread Worker;
FCriticalSection Mutex;
TArray<uint8> Pending;
bool bHasNew = false;
};
void ADepthReceiver::BeginPlay()
{
Super::BeginPlay();
DepthTexture = UTexture2D::CreateTransient(640, 576, PF_G8);
DepthTexture->UpdateResource();
Socket = ISocketSubsystem::Get(PLATFORMSUBSYSTEM)
->CreateSocket(NAME_DGram, TEXT("DepthUDP"), false);
TSharedRef<FInternetAddr> Addr =
ISocketSubsystem::Get(PLATFORMSUBSYSTEM)->CreateInternetAddr();
bool bValid = false;
Addr->SetIp(TEXT("127.0.0.1"), bValid);
Addr->SetPort(7777);
Socket->Bind(Addr);
bRunning = true;
Worker = std::thread([this] { ListenLoop(); });
}
Шаг 3. Приём в фоновом потоке
Сеть живёт в отдельном потоке: блокирующий Recv не тормозит рендер, последний пришедший кадр кладём в буфер под мьютексом.
void ADepthReceiver::ListenLoop()
{
TArray<uint8> Buf;
while (bRunning)
{
Buf.SetNumUninitialized(8 + 640 * 576);
int32 Read = 0;
if (Socket && Socket->Recv(Buf.GetData(), Buf.Num(), Read,
ESocketReceiveFlags::Blocking) && Read > 8)
{
FScopeLock Lock(&Mutex);
Pending = MoveTemp(Buf);
bHasNew = true;
}
}
}
Шаг 4. Обновление текстуры на игровом треде
На Tick забираем кадр из буфера и заливаем в текстуру через RHI-команду. Заголовок даёт размеры кадра — так текстуру можно создавать один раз под известный профиль или пересоздавать при смене:
static void UpdateDepthTexture(UTexture2D* Texture, int32 W, int32 H, uint8* Data)
{
if (!Texture || !Texture->GetResource()) { delete[] Data; return; }
FUpdateTextureRegion2D Region(0, 0, 0, 0, W, H);
ENQUEUE_RENDER_COMMAND(FUploadDepth)(
[Texture, W, Region, Data](FRHICommandListImmediate&)
{
FTexture2DResource* Res =
(FTexture2DResource*)Texture->GetResource();
RHIUpdateTexture2D(Res->GetTexture2D()->GetRHI(), 0, Region, W, Data);
delete[] Data;
});
}
void ADepthReceiver::Tick(float Dt)
{
Super::Tick(Dt);
TArray<uint8> Frame;
{
FScopeLock Lock(&Mutex);
if (!bHasNew) return;
Frame = MoveTemp(Pending);
bHasNew = false;
}
int32 W = 0, H = 0;
FMemory::Memcpy(&W, Frame.GetData(), 4);
FMemory::Memcpy(&H, Frame.GetData() + 4, 4);
const int32 Bytes = W * H;
if (Frame.Num() < 8 + Bytes) return;
uint8* Data = new uint8[Bytes];
FMemory::Memcpy(Data, Frame.GetData() + 8, Bytes);
UpdateDepthTexture(DepthTexture, W, H, Data);
}
Осталось EndPlay: остановить поток и закрыть сокет. Текстуру подключайте в материал через динамический экземпляр (SetTextureParameterValue) — и в материале байт глубины становится любым эффектом: контур силуэта, маска отсечения, вход в шум для частиц.
Шаг 5. Материал: что делать с байтами глубины
Самый ходовой приём — контурная подсветка: сэмплируйте текстуру, вычтите из неё ту же текстуру со сдвигом UV, возьмите модуль разности — получится контур, толщина которого задаётся величиной сдвига. Второй приём — пороговая маска: everything ближе N байт к камере становится прозрачным (LinearThreshold через SmoothStep даёт мягкий край вместо «рваного»). Третий — драйв частиц: байт глубины как масштаб эмиттера в Niagara — частицы «сдуваются» с посетителя. Начните с контура: он сразу даёт проверяемый эффект и заодно диагностирует качество канала — если контур рваный, увидите это раньше, чем в сложной композиции.
Разбор кода
Маппинг глубины в 8 бит — самое важное решение пайплайна. Линейная шкала на весь диапазон 0,1–5,5 м даёт для ближней зоны всего ~25 уровней на метр — для силуэтной логики этого достаточно, но если работаете вблизи (1–2 м), сожмите диапазон D_MIN/D_MAX под сцену: 254 уровня на метр превращают текстуру в точный инструмент. Альтернативы — логарифмическая шкала (равномерная относительная точность) или два диапазона; всё это меняется одной строкой в сайдкаре, движок получает уже готовую шкалу.
Потокобезопасность здесь устроена канонически: сеть читает в фоновом потоке и кладёт кадр под мьютекс, а все операции с текстурой выполняет только игровой тред в Tick, сама загрузка на GPU — рендер-командой через ENQUEUE_RENDER_COMMAND. Попытка обновлять UTexture2D прямо из фонового потока — гарантированный краш или гонка; двойная буферизация «последний кадр» заодно решает проблему отставания: если игра подтормозила, мы не копим очередь из сети, а всегда рисуем самое свежее.
UDP на localhost — сознательный выбор: потеря пакета означает один пропущенный кадр, что для 30 Гц невидимо; TCP в этой схеме создаёт очередь задержек хуже потери. Если кадры уйдут с машины — либо увеличьте буферы сокета (SetReceiveBufferSize), либо переходите на TCP/общую память; для одного хоста, где и камера, и UE, localhost-UDP практичен.
Когда оправдан прямой SDK в C++-плагине UE: если задержка критична до миллисекунд, если не хотите внешнего процесса в продакшене или если камер несколько и нужен общий контроль. Реализация — FRunnable-поток, инициализация Orbbec SDK v2 теми же вызовами, что в Python, и та же передача кадров через буфер в игровой тред. Цена — SDK-зависимости в сборке движка: каждую ревизию SDK придётся пересобирать с UE, а диагностика сместится в один процесс, что сложнее. Для прототипа и большинства инсталляций сайдкар выигрывает по совокупности усилий, для тиражного плагина — наоборот.
| Решение | Плюсы | Когда выбирать |
|---|---|---|
| Python-сайдкар + UDP | SDK вне сборки UE, быстрая отладка, гибкая шкала | инсталляции, прототипы, один хост |
| SDK на C++ внутри плагина | минимальная задержка, нет внешнего процесса | продуктовый плагин, распределённая система |
| Общая память вместо UDP | нет сетевого стека, ещё ниже задержка | строгие требования к лагу |
| 8-битная глубина PF_G8 | минимальный трафик, любые материалы | почти всегда; 16 бит — под метрологию |
Согласуйте и геометрию: камера смотрит на зону с потолка под углом, а в UE у вас ортогональный экран — прямого соответствия экрана и кадра нет. Для силуэтных эффектов достаточно «привязать» кадр к зоне визуально (масштаб и позиция текстуры на материале), для точных наложений — проективное преобразование UV в материале по четырём угловым точкам зоны.
Частые проблемы
Текстура чёрная, хотя сайдкар шлёт данные
Проверьте по цепочке: приходит ли трафик (счётчик пакетов в диспетчере задач), совпадает ли порт, совпадает ли размер в заголовке кадра с размером текстуры — при несовпадении мы молча отбрасываем кадр. Если UE собран из-под отладчика и Tick актора не тикает, убедитесь, что актор добавлен на сцену и у него включён Tick. Наконец, в материалах помните про sRGB: для PF_G8-текстур с данными (не картинками) снимите флаг sRGB у создаваемой текстуры — иначе байты поедут через гамма-кривую и пороговая логика поплывёт.
Кадры идут рывками, периодами всё замирает
Три типовых виновника. Первый — маленький буфер UDP-сокета: при всплесках ядро молча роняет пакеты, увеличьте приёмный буфер. Второй — сайдкар не успевает: понижайте разрешение depth-профиля, 640-кратной шкалы для материалов обычно достаточно. Третий — антивирус/файрвол инспектирует localhost-трафик; правило исключения для Python-процесса снимает задержки. Диагностика простая: логируйте в Tick разницу времени между кадрами — рывок виден в числах раньше, чем на экране.
Камера и движок на разных машинах
Как только сайдкар уезжает с хоста UE, у localhost-UDP заканчивается магия: по реальной сети пакетный UDP-поток в сотни мегабит требует и гигабитной линии, и увеличенных буферов, и терпимости к потерям. Практика инсталляций: если машины в одном шкафу и связаны коротким кабелем — UDP доживёт, но заложите проверку потерь. Дальше по нарастающей: TCP с внутренней фреймовой меткой (теряем latency, но не кадры), либо захват на стороне камеры — решение класса Persee, где вычисления живут прямо у камеры и наружу идёт уже готовый компактный результат.
Краш при выходе или пересоздании уровня
Фоновый поток обязан остановиться до разрушения актора: в EndPlay сбросьте атомарный флаг и присоедините поток, и только потом уничтожайте сокет. Если крашится именно на закрытии сокета — поток ещё читает из него; порадоксу обработки ошибок Recv после bRunning=false помогайте закрытием сокета с другой стороны или таймаутом приёма. И не держите ссылку на текстуру из фонового потока — она используется только в Tick игрового треда.
Камера для таких инсталляций есть в каталоге Orbbec на luwam.ru. Стандартный выбор — Femto Bolt (iToF 0,1–5,5 м, RGB 4K, наследник Azure Kinect DK по формату применения), запасной вариант для узких сцен — Gemini 335L. Поможем с выбором и консультацией по SDK, выставим счёт для юрлица, гарантия 1 год.
Камеры Orbbec в наличии на LUWAM
- Orbbec Femto Bolt — iToF 4K, замена Azure Kinect — 99 900 ₽
- Orbbec Gemini 335L — стерео для роботов и SLAM — 99 900 ₽
- Orbbec Astra 2 — структурированный свет, сканирование помещений — 99 900 ₽
Весь каталог Orbbec · консультация по SDK · счёт для юрлица в день обращения · гарантия 1 год.

