В этой статье я хочу рассказать вам о том, как мы писали с нуля полнофункциональный Gaussian Splatting вьюер для Unreal Engine 5.

Начало работы

Прежде всего, давайте быстро вспомним, что такое гауссов сплэтинг. Вкратце, это процесс, который создает нечто похожее на облако точек, только вместо точек используются цветные эллиптические формы (splats или 3D-гауссианы). Они изменяются и растягиваются в зависимости от положения камеры и перспективы, сливаясь в непрерывное представление пространства. Этот метод позволяет сохранить в захваченном виртуальном макете такую визуальную информацию, как отражения и светотени, как можно более реалистично передавая его детали.

Если вам интересно почитать об этом методе поподробнее, то рекомендую ознакомиться с моими предыдущими статьями:

Первой нашей задачей было понять, какой именно формат использовать для файла с 3D-гауссианами и какой из них более распространен в индустрии. Проведя глубокое исследование мы определили два основных формата, которые популярны в настоящее время: .ply и .splat.

После некоторых раздумий мы выбрали формат .ply, поскольку он охватывает более широкий спектр применения. Это решение было также обусловлено рассмотрением других инструментов, таких как Super Splat, который позволяет импортировать 3D-гауссианы только в виде .ply-файлов (хотя он также предлагает возможность экспортировать их в .splat-файлы).

Что из себя представляет .PLY-файл?

Для начала, существует два различных типа .ply-файлов:

  • .ply-файлы с ASCII, в которых данные хранятся в текстовом виде.

  • Двоичные .ply-файлы с куда худшей читабельностью.

Вы можете рассматривать .ply как очень гибкий формат для задания множества точек и их атрибутов (в теле файла), а также набора свойств, определяемых в его заголовке. С их помощью он указывает парсеру, как следует интерпретировать данные, содержащиеся в его теле. Для справки, вот очень информативное руководство по общей структуре .ply-файлов.

Ниже представлен пример того, как выглядит типичный .ply-файл для гауссова сплэтинга:

ply
format binary_little_endian 1.0
element vertex 1534456
property float x
property float y
property float z
property float nx
property float ny
property float nz
property float f_dc_0
property float f_dc_1
property float f_dc_2
property float f_rest_0
(... f_rest from 1 to  43...)
property float f_rest_44
property float opacity
property float scale_0
property float scale_1
property float scale_2
property float rot_0
property float rot_1
property float rot_2
property float rot_3
end_header
  • Первая строка подтверждает, что это .ply-файл. 

  • Вторая строка определяет, является ли формат данных, хранящихся после заголовка, ASCII или двоичным (в данном примере — двоичный).

  • Третья строка сообщает парсеру, сколько элементов содержит файл. В нашем примере 1534456 элементов, то есть 3D-гауссиан.

  • Начиная с четвертой строки и до строки "end_header" описывается структура каждого элемента в виде набора свойств, каждое из которых имеет свой тип данных и имя. Как правило большинство .ply-файлов с гауссовым сплэтингом следуют порядку этих свойств. Стоит отметить, что независимо от порядка, важным правилом является то, что в файле должны быть определены все неопциональные свойства, а данные, в свою очередь, должны соответствовать заявленной структуре.

После секции с заголовком следует тело файла с данными элементов. Для корректного парсинга каждый элемент должен строго соблюдать порядок, объявленный в заголовке.

Это может дать вам представление о том, чего следует ожидать, когда мы хотим описать один элемент Gaussian Splat, загруженный из ply.файла:

  • Положение в пространстве в виде XYZ (x, y, z);

  • [Опционально] Векторы нормали (nx, ny, nz);

  • Сферические функции нулевого порядка (f_dc_0, f_dc_1, f_dc_2), которые определяют, какой цвет должен быть у отдельной 3D-гауссианы, используя специальную математическую формулу, вычисляющую результирующее RGB-значение для рендеринга;

  • [Опционально] Сферические функции высшего порядка (от f_rest_0 до f_rest_44), которые определяют, как должен меняться цвет 3D-гауссианы в зависимости от положения камеры. Это необходимо для повышения реалистичности информации об отражении или освещении, записанной в 3D-гауссиану. Стоит отметить, что эта информация необязательна, и файлы, в которые она записана, будут весить намного больше, чем файлы, содержащие только функции нулевого порядка;

  • Непрозрачность (opacity), которая определяет прозрачность 3D-гауссианы;

  • Масштаб в виде XYZ (scale_0, scale_1, scale_2);

  • Ориентация в пространстве в виде кватернионов WXYZ (rot_0, rot_1, rot_2, rot_3).

Вся эта информация имеет свою собственную систему координат, которую после загрузки необходимо преобразовать в систему Unreal Engine. Более подробно об этом мы расскажем чуть позже.

Теперь, когда вы знакомы с данными, с которыми нам предстоит работать, мы готовы перейти к следующему шагу.

Парсинг .PLY-файла в Unreal

В нашей реализации мы хотели поддерживать как ASCII, так и бинарные ply.файлы, поэтому нам нужен был способ быстро парсить и хранить их данные соответствующим образом. К счастью, .ply-файлы — не новинка. Они использовались для 3D-моделей уже давно, еще до того, как гауссов сплэтинг стал популярным. Поэтому на GitHub есть несколько парсеров формата .ply, которые мы могли использовать для этой цели. Мы решили адаптировать реализацию Happly — написанного на C++ универсального header-only парсера ply с открытым исходным кодом (огромное спасибо автору Николасу Шарпу).

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

Следующим логическим шагом, после того как мы разобрались с тем, как выглядят данные и как их читать из файла, было их хранение. Это означало, что нам нужен класс или структура, которая могла бы хранить все эти данные в движке. Настало время покопаться в C++ коде!

Как мы можем определить 3D-гауссиану в Unreal?

Самым простым способом хранения данных 3D-гауссианы в Unreal было определение пользовательского USTRUCT, опционально доступного через Blueprints, который выглядит следующим образом:

/**
 * Представляет собой данные, полученные в результате парсинга 3D-гауссианы, загруженные из PLY-файла.
 */
USTRUCT(BlueprintType)
struct FGaussianSplatData
{

GENERATED_BODY()

// Положение 3D-гауссианы (x, y, z)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FVector Position;

// Векторы нормали [опционально] (nx, ny, nz)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FVector Normal;

// Ориентация 3D-гауссианы в виде wxyz из PLY (rot_0, rot_1, rot_2, rot_3)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FQuat Orientation;

// Масштаб 3D-гауссианы (scale_0, scale_1, scale_2)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FVector Scale;

// Непрозрачность 3D-гауссианы (opacity)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float Opacity;

// Коэффициенты сферических функций - нулевого порядка (f_dc_0, f_dc_1, f_dc_2)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FVector ZeroOrderHarmonicsCoefficients;

// Коэффициенты сферических функций - высшего порядка (f_rest_0, ..., f_rest_44)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FVector> HighOrderHarmonicsCoefficients;

FGaussianSplatData()
: Position(FVector::ZeroVector)
	, Normal(FVector::ZeroVector)
	, Orientation(FQuat::Identity)
	, Scale(FVector::OneVector)
	, Opacity(0)
	{
	}
};

Таким образом мы генерируема на этапе парсинга по экземпляру данной структуры на каждую 3D-гауссиану и добавляем их в массив TArray, чтобы использовать эти данные для визуализации на следующих этапах.

Теперь, когда мы разобрались с данными, перейдем к самой сложной и интересной части: передаче данных на GPU, чтобы система Niagara могла их прочитать!

Почему именно Niagara?

Niagara — идеальный кандидат для представления частиц в Unreal. В частности, система Niagara состоит из одного или нескольких эмиттеров, которые отвечают за порождение частиц и обновление их состояния каждый кадр.

Конкретном в нашем случае для создания базовой реализации мы будем использовать один эмиттер Niagara. Для наглядности назовем его "GaussianSplatViewer".

Теперь, когда у нас есть наш новый прекрасный эмиттер, нам нужен способ "передать" в него данные о 3D-гауссианах, чтобы для каждой из них мы могли породить соответствующую точку в пространстве, представляющую ее. Вы можете задаться вопросом, а есть ли в Unreal для этого какое-нибудь решение, которое мы могли бы использовать из коробки? Ответ — да, и называется оно "Niagara Data Interface (NDI)".

Что такое Niagara Data Interface (NDI) и как его использовать?

Представьте, что вы хотите сказать эмиттеру Niagara: "Я считываю из файла множество точек и хочу отобразить их в виде частиц. Как сделать так, чтобы ты понял, в какой позиции должна находиться каждая точка?" Niagara ответит: "Дай мне корректный NDI, с помощью которого я смогу понять твои данные, а затем извлечь из него позицию для каждой частицы".

На этом этапе вы можете задаться вопросом, как написать NDI и где вообще искать к нему документацию? Ответ прост: львиная доля исходного кода Unreal Engine использует NDI для пользовательских систем частиц, и они, в свою очередь, являются отличным источником вдохновения для создания собственных! Больше всего мы вдохновились "UNiagaraDataInterfaceAudioOscilloscope".

Нам нужно было структурировать наш пользовательский NDI так, чтобы каждая 3D-гауссиана была "понятна" Niagara при ее передаче. Помните, что в этом классе будет храниться список гауссиан, который мы загрузили из .ply-файла, чтобы мы могли получить из него доступ к их данным и преобразовать их в совместимые с Niagara типы данных, которые будут использоваться внутри частиц.

Во-первых, мы хотим, чтобы наш NDI-класс наследовался от UNiagaraDataInterface — интерфейса, который нужен Niagara для работы с пользовательскими типами данных через NDI. Чтобы полностью реализовать этот интерфейс, нам нужно переопределить несколько функций, о которых мы поговорим ниже.

Переопределение GetFunctions

Переопределяя эту функцию, мы говорим Niagara: "Я хочу, чтобы ты видела список функций, которые я определяю, чтобы я мог использовать их в твоих модулях". Таким образом, система узнает, какие входные и выходные данные должны быть у каждой из этих функций, их имена и то, является ли она статической или нестатической.

// Определяем функции, которые мы хотим передать системе Niagara из
// нашего NDI. Например, мы определяем функцию для извлечения позиций из
// данных с гауссовым сплеэтингом.
virtual void GetFunctions(TArray<FNiagaraFunctionSignature>& OutFunctions) override;

Вот так будет выглядеть реализация GetFunctions, которая определяет функцию GetSplatPosition для системы Niagara. Мы хотим, чтобы у GetSplatPosition было ровно 2 параметра и 1 результат:

  • Параметр, ссылающийся на NDI, в котором хранится массив 3D-гауссиан (необходим для доступа к данным гауссиан через этот NDI из scratchpad-модуля системы Niagara);

  • Параметр типа integer, который указывает, позицию какой из гауссиан мы запрашиваем (он будет совпадать с ID частицы из эмиттера Niagara, чтобы каждая частица получила позицию соответствующей ей 3D-гауссианы);

  • Результат типа Vector3, который возвращает позицию XYZ конкретной 3D-гауссианы, полученной по предоставленному индексу.

void UGaussianSplatNiagaraDataInterface::GetFunctions(
    TArray<FNiagaraFunctionSignature>& OutFunctions)
{   
   // Получаем позицию частицы, считывая ее из нашего массива гауссиан по индексу
   FNiagaraFunctionSignature Sig;
   Sig.Name = TEXT("GetSplatPosition");
   Sig.Inputs.Add(FNiagaraVariable(FNiagaraTypeDefinition(GetClass()),
       TEXT("GaussianSplatNDI")));
   Sig.Inputs.Add(FNiagaraVariable(FNiagaraTypeDefinition::GetIntDef(),
       TEXT("Index")));
   Sig.Outputs.Add(FNiagaraVariable(FNiagaraTypeDefinition::GetVec3Def(),
       TEXT("Position")));
   Sig.bMemberFunction = true;
   Sig.bRequiresContext = false;
   OutFunctions.Add(Sig);
}

Аналогичным образом мы определим в GetFunctions и другие функции, чтобы получить масштаб, ориентацию, непрозрачность, сферические функции и количество частиц наших 3D-гауссиан. Частицы будут использовать эту информацию для изменения формы, цвета и положения в пространстве.

Переопределение GetVMExternalFunction

Это переопределение необходимо для того, чтобы Niagara могла использовать в своих узлах функции, объявленные нами в GetFunctions. Таким образом они станут доступны в графах и scratchpad-модулях Niagara. В Unreal есть предназначенный для этих целей макрос DEFINE_NDI_DIRECT_FUNC_BINDER, который мы также будем использовать. Ниже приведен пример определения функции GetSplatPosition.

// Мы биндим эту функцию, чтобы ее можно было использовать в графе Niagara
DEFINE_NDI_DIRECT_FUNC_BINDER(UGaussianSplatNiagaraDataInterface, GetSplatPosition);


void UGaussianSplatNiagaraDataInterface::GetVMExternalFunction(const FVMExternalFunctionBindingInfo& BindingInfo, void* InstanceData, FVMExternalFunction& OutFunc)
{
   if(BindingInfo.Name == *GetPositionFunctionName)
   {
       NDI_FUNC_BINDER(UGaussianSplatNiagaraDataInterface,
         GetSplatPosition)::Bind(this, OutFunc);
   }
}


// Функция, определенная под работу на CPU, понятная Niagara
void UGaussianSplatNiagaraDataInterface::GetSplatPosition(
  FVectorVMExternalFunctionContext& Context) const
{
   // Входные параметры - NDI и индекс частицы
   VectorVM::FUserPtrHandler<UGaussianSplatNiagaraDataInterface> 
     InstData(Context);


   FNDIInputParam<int32> IndexParam(Context);
  
   // Результат с положением
   FNDIOutputParam<float> OutPosX(Context);
   FNDIOutputParam<float> OutPosY(Context);
   FNDIOutputParam<float> OutPosZ(Context);


   const auto InstancesCount = Context.GetNumInstances();


   for(int32 i = 0; i < InstancesCount; ++i)
   {
       const int32 Index = IndexParam.GetAndAdvance();


       if(Splats.IsValidIndex(Index))
       {
           const auto& Splat = Splats[Index];
           OutPosX.SetAndAdvance(Splat.Position.X);
           OutPosY.SetAndAdvance(Splat.Position.Y);
           OutPosZ.SetAndAdvance(Splat.Position.Z);
       }
       else
       {
           OutPosX.SetAndAdvance(0.0f);
           OutPosY.SetAndAdvance(0.0f);
           OutPosZ.SetAndAdvance(0.0f);
       }
   }
}

Обратите внимание, что GetSplatPosition определена таким образом для совместимости NDI с CPU.

Переопределение копирования и равенства

Нам также нужно переопределить эти функции, чтобы Niagara понимала, как выполнять копирование или сравнение NDI, которые использует наш класс. А именно, мы поручаем движку копировать список 3D-гауссиан при копировании данного NDI в новый, а также определять, одинаковы ли два NDI, на основе данных для гауссиан.

virtual bool CopyToInternal(UNiagaraDataInterface* Destination) const override;
virtual bool Equals(const UNiagaraDataInterface* Other) const override;

Эта функция необходима для того, чтобы система Niagara понимала, где должны выполняться наши NDI-функции — на CPU или на GPU. В данном случае, изначально мы хотели, чтобы она работала на CPU (для отладки), но для релизной версии мы изменим ее таким образом, чтобы она работала на GPU. Я объясню причину такого выбора позже.

virtual bool CanExecuteOnTarget(ENiagaraSimTarget Target) const override { return Target == ENiagaraSimTarget::GPUComputeSim; }

Дополнительные переопределения, необходимые для того, чтобы наш NDI работал и на GPU

Чтобы мы могли указать Niagara, как наши данные будут храниться на GPU и как объявленные нами функции будут преобразованы для GPU с помощью шейдерного HLSL-кода (подробнее об этом позже), нам нужно переопределить следующие функции:

// Определения HLSL для GPU
virtual void GetParameterDefinitionHLSL(const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, FString& OutHLSL) override;


virtual bool GetFunctionHLSL(const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, const FNiagaraDataInterfaceGeneratedFunction& FunctionInfo, int FunctionInstanceIndex, FString& OutHLSL) override;


virtual bool UseLegacyShaderBindings() const override { return false; }


virtual void BuildShaderParameters(FNiagaraShaderParametersBuilder& ShaderParametersBuilder) const override;


virtual void SetShaderParameters(const FNiagaraDataInterfaceSetShaderParametersContext& Context) const override;

Системы Niagara на базе CPU и GPU

Каждый эмиттер системы частиц Niagara может работать как на CPU, так и на GPU. Очень важно сразу определиться, какой из этих двух вариантов мы в конечном итоге выберем, потому что каждый из них имеет свои побочные эффекты.

В первоначальной реализации мы использовали эмиттер Niagara на базе CPU. Это было необходимо для того, чтобы убедиться, что данные и координаты 3D-гауссиан корректно воспроизводятся с точки зрения положения, ориентации и масштаба в системе Niagara.

Однако у эмиттеров на базе CPU есть ряд важных ограничений: 

  • Они не могут породить более 100 000 частиц;

  • Они полагаются только на CPU, а значит, каждый кадр может отнимать дополнительное время от выполнения других скриптов, что приводит к снижению частоты кадров, особенно при работе с максимальным количеством поддерживаемых частиц;

  • GPU могут справляться с чрезвычайно параллельными задачами гораздо лучше, чем CPU. Это делает GPU лучшим выбором для работы с большими объемами частиц.

Хотя для отладки и можно потерпеть ограничение процессора в 100 тысяч частиц, это определенно не тот показатель, который нужна для нашего масштаба. Нам нужно поддерживать большие файлы, которые могут содержать миллионы частиц.

Во второй итерации мы решили перейти на эмиттер на базе GPU. Он не только полностью полагается на GPU, не затрагивая CPU, но и может поддерживать до 2 миллионов порождаемых частиц, что в 20 раз больше, чем на CPU.

Побочным эффектом выполнения на GPU является то, что нам также необходимо позаботиться о распределении и управлении ресурсами GPU, что требует от нас работы с шейдерным HLSL-кодом и преобразования данных между CPU и GPU.

Как это сделать? Как вы уже могли догадаться, расширив наш прекрасный пользовательский NDI.

Из .PLY-файла в GPU через NDI

Благодаря нашему пользовательскому NDI мы имеем полный контроль над тем, как наши данные хранятся в памяти и как они преобразуются в совместимую с Niagara форму. Теперь задача состоит в том, чтобы реализовать это в коде. Мы разобьем эту задачу на две части:

  • Выделим память на GPU для хранения данных гауссова сплэтинга, поступающих от CPU.

  • Перенесем данные гауссова сплэтинга из CPU в подготовленную память GPU.

Подготовка памяти GPU для хранения данных гауссова сплэтинга

Первое, о чем следует знать, это то, что мы не можем использовать типы данных Unreal, такие как TArray (в котором в нашем NDI хранится список 3D-гауссиан), при определении данных на GPU. Это связано с тем, что TArray предназначен для CPU и хранится в оперативной памяти, доступ к которой имеет только CPU. GPU же имеет свою собственную отдельную память (VRAM) и требует особые типы структур данных в целях оптимизации доступа, скорости и эффективности.

Для хранения коллекций данных на GPU нам потребовалось использовать буферы GPU. Существуют различные типы буферов:

  • Буферы вершин (Vertex Buffers): хранят такие параметры вершин, как положение, нормали и текстурные координаты;

  • Индексные буферы (Index Buffers): используются для указания GPU порядка, в котором вершины должны быть обработаны для формирования примитивов;

  • Буферы констант (Constant Buffers): хранят такие значения, как матрицы трансформации и свойства материалов, которые остаются постоянными для многих операций при рендеринге кадра;

  • Структурированные буферы (Structured Buffers) и буферы хранения шейдеров (Shader Storage Buffers): более гибкие, поскольку могут хранить широкий спектр типов данных, нужны для сложных операций.

В нашем случае я решил использовать простую реализацию, в которой каждый тип информации о 3D-гауссиане хранится в определенном буфере (буфер позиций, буфер масштабов, буфер ориентаций, буфер сферических функций и непрозрачности).

Обратите внимание, что и буферы, и текстуры — одинаково пригодные структуры, которые можно рассматривать для данных гауссиан на GPU. Мы выбрали буферы, так как считаем, что их реализация более читабельна, а также позволяла избежать проблему с подходом на основе текстур, когда последний ряд пикселей часто оказывался не полностью заполненным.

Чтобы объявить эти буферы в Unreal, нам нужно добавить определение для "Shader parameter struct", которое использует макрос Unreal Engine, чтобы сообщить движку, что это структура данных, поддерживаемая шейдерами HLSL (следовательно, поддерживаемая операциями GPU):

BEGIN_SHADER_PARAMETER_STRUCT(FGaussianSplatShaderParameters, )
   SHADER_PARAMETER(int, SplatsCount)
   SHADER_PARAMETER(FVector3f, GlobalTint)
   SHADER_PARAMETER_SRV(Buffer<float4>, Positions)
   SHADER_PARAMETER_SRV(Buffer<float4>, Scales)
   SHADER_PARAMETER_SRV(Buffer<float4>, Orientations)
   SHADER_PARAMETER_SRV(Buffer<float4>, SHZeroCoeffsAndOpacity)
END_SHADER_PARAMETER_STRUCT()

Стоит отметить, что эти буферы могут быть оптимизированы, поскольку координата W остается неиспользованной в позиционировании и масштабировании (им нужен только XYZ). Для уменьшения занимаемой ими памяти мы могли бы применить техники упаковки по каналам (channel packing), но это уже выходит за рамки данной статьи. Также в целях оптимизации можно использовать половинную точность вместо полной плавающей запятой.

Перед буферами мы также определяем целое число для отслеживания количества гауссиан, которые нам нужно обработать (SplatsCount), и вектор GlobalTint, представляющий RGB-значение, которое мы можем использовать для изменения оттенка наших гауссиан. Это определение помещается в заголовочный файл нашего класса NDI.

Нам также нужно внедрить пользовательский код шейдера для GPU, который объявит наши буферы, чтобы на них можно было ссылаться в дальнейшем и использовать в пользовательских шейдерными функциями. Мы сообщаем это Niagara через переопределение GetParameterDefinitionHLSL:

void UGaussianSplatNiagaraDataInterface::GetParameterDefinitionHLSL(
  const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, FString& OutHLSL)
{
  Super::GetParameterDefinitionHLSL(ParamInfo, OutHLSL);


  OutHLSL.Appendf(TEXT("int %s%s;\n"), 
    *ParamInfo.DataInterfaceHLSLSymbol, *SplatsCountParamName);
  OutHLSL.Appendf(TEXT("float3 %s%s;\n"),
    *ParamInfo.DataInterfaceHLSLSymbol, *GlobalTintParamName);
  OutHLSL.Appendf(TEXT("Buffer<float4> %s%s;\n"),
    *ParamInfo.DataInterfaceHLSLSymbol, *PositionsBufferName);
  OutHLSL.Appendf(TEXT("Buffer<float4> %s%s;\n"),
    *ParamInfo.DataInterfaceHLSLSymbol, *ScalesBufferName);
  OutHLSL.Appendf(TEXT("Buffer<float4> %s%s;\n"),
    *ParamInfo.DataInterfaceHLSLSymbol, *OrientationsBufferName);
  OutHLSL.Appendf(TEXT("Buffer<float4> %s%s;\n"),
    *ParamInfo.DataInterfaceHLSLSymbol, *SHZeroCoeffsBufferName);

По сути, это означает, что система Niagara, использующая наш пользовательский NDI, будет иметь этот код шейдера «под капотом». Это позволит нам ссылаться на эти буферы GPU в нашем шейдерном HLSL‑коде на последующих этапах. Чтобы сделать код более удобным в сопровождении мы определили имена параметров как FString.

Передача данных гауссова сплэтинга с CPU на GPU

Теперь самая сложная часть: нам нужно «заполнить» буферы GPU, используя код на C++ в качестве моста между памятью CPU и памятью GPU, определяющим способ передачи этих данных.

Для этого мы решили внедрить пользовательский «прокси интерфейса данных Niagara» — структуру данных, используемую в качестве «моста» между CPU и GPU. Этот прокси помог нам передавать данные из буферов на стороне CPU в буферы, объявленные как параметры шейдеров для GPU. Для этого мы определили в прокси буферы и функции для их инициализации и обновления.

Я понимаю, что все это кажется очень сложным, но с точки зрения логики все довольно просто. Вся концепция умещается на этой диаграмме:

Теперь, когда у нас есть полное представление о нашей системе, чтобы она заработала в полную силу, осталось доработать только пару мелких деталей.

Мы уже получили определения буферов для GPU в виде HLSL-кода с помощью функции GetParameterDefinitionHLSL. Теперь нам нужно сделать то же самое для функций, которые мы ранее определили в GetFunctions. Это нужно, чтобы GPU понял, как перевести их в шейдерный HLSL-код.

Возьмем для примера функцию GetSplatPosition. Ранее мы видели, как она была определена для использования на CPU. Теперь нам нужно расширить ее определение, чтобы она была объявлена и для GPU. Мы можем сделать это, переопределив GetFunctionHLSL в нашем пользовательском NDI:

bool UGaussianSplatNiagaraDataInterface::GetFunctionHLSL(
  const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, const
  FNiagaraDataInterfaceGeneratedFunction& FunctionInfo, 
  int FunctionInstanceIndex, FString& OutHLSL)
{
   if(Super::GetFunctionHLSL(ParamInfo, FunctionInfo,
     FunctionInstanceIndex, OutHLSL))
  {
    // Если функция уже определена в родительском классе, 
    // то ее определение не нужно дублировать.
    return true;
  }
  
  if(FunctionInfo.DefinitionName == *GetPositionFunctionName)
  {
    static const TCHAR *FormatBounds = TEXT(R"(
      void {FunctionName}(int Index, out float3 OutPosition)
      {
        OutPosition = {PositionsBuffer}[Index].xyz;
      }
    )");
    const TMap<FString, FStringFormatArg> ArgsBounds =
    {
     {TEXT("FunctionName"), FStringFormatArg(FunctionInfo.InstanceName)},
     {TEXT("PositionsBuffer"),
       FStringFormatArg(ParamInfo.DataInterfaceHLSLSymbol + 
         PositionsBufferName)},
    };
    OutHLSL += FString::Format(FormatBounds, ArgsBounds);
  }
  else
  {
    // Возвращаем false, если имя функции не совпадает ни с одной из ожидаемых.
    return false;
  }
  return true;
}

Как видите, эта часть кода просто добавляет в строку OutHLSL HLSL-код шейдера, который реализует нашу функцию GetSplatPosition для GPU. Всякий раз, когда Niagara выполняется на GPU и функция GetSplatPosition вызывается графом Niagara, будет выполняться этот код шейдера.

Для краткости я не стал приводить код других HLSL-шейдеров для функций-геттеров масштабирования, ориентации, сферических функций и непрозрачности. Однако идея та же, мы просто добавим их внутрь GetFunctionHLSL.

Наконец, собственно сам код для передачи данных от CPU к GPU через DIProxy обрабатывается переопределением SetShaderParameters:

void UGaussianSplatNiagaraDataInterface::SetShaderParameters(
  const FNiagaraDataInterfaceSetShaderParametersContext& Context) const
{
  // Инициализируем параметры шейдера, чтобы они были одинаковыми с 
  // нашими буферами в прокси
  FGaussianSplatShaderParameters* ShaderParameters =
    Context.GetParameterNestedStruct<FGaussianSplatShaderParameters>();
  if(ShaderParameters)
  {
    FNDIGaussianSplatProxy& DIProxy = 
      Context.GetProxy<FNDIGaussianSplatProxy>();


      if(!DIProxy.PositionsBuffer.Buffer.IsValid())
      {
        // Инициализация буферов 
        DIProxy.InitializeBuffers(Splats.Num());
      }


      // Константы
      ShaderParameters->GlobalTint = DIProxy.GlobalTint;
      ShaderParameters->SplatsCount = DIProxy.SplatsCount;
      // Назначаем инициализированные буферы параметрам шейдера
      ShaderParameters->Positions = DIProxy.PositionsBuffer.SRV;
      ShaderParameters->Scales = DIProxy.ScalesBuffer.SRV;
      ShaderParameters->Orientations = DIProxy.OrientationsBuffer.SRV;
      ShaderParameters->SHZeroCoeffsAndOpacity =
        DIProxy.SHZeroCoeffsAndOpacityBuffer.SRV;
  }
}

В частности, происходит передача данных буфера из NDI-прокси (DIProxy) в соответствующие параметры HLSL-шейдера, управляемые структурой FGaussianSplatShaderParameters.

Это довольно много кода! Если вам удалось проделать весь этот путь, поздравляем! Теперь вы практически закончили с низкоуровневой реализацией. Давайте вернемся на один уровень назад и допишем некоторые остатки, чтобы завершить работу над вьюером!

Регистрируем наши пользовательские NDI и NDI-прокси в Niagara

И последнее, что требуется для доступа к нашему пользовательскому NDI внутри типов свойств Niagara, это регистрация его в реестре FNiagaraTypeRegistry. Для удобства мы решили сделать это в PostInitProperties нашего NDI, где мы также создадим NDI-прокси, который будет передавать данные от CPU к GPU.

void UGaussianSplatNiagaraDataInterface::PostInitProperties()
{


  Super::PostInitProperties();


  // Создаем прокси, который мы будем использовать для передачи данных между CPU и GPU
  // (требуется для поддержки системы Niagara на базе GPU).
  Proxy = MakeUnique<FNDIGaussianSplatProxy>();
 
  if(HasAnyFlags(RF_ClassDefaultObject))
  {
    ENiagaraTypeRegistryFlags DIFlags =
      ENiagaraTypeRegistryFlags::AllowAnyVariable |
      ENiagaraTypeRegistryFlags::AllowParameter;


    FNiagaraTypeRegistry::Register(FNiagaraTypeDefinition(GetClass()), DIFlags);
  }


  MarkRenderDataDirty();
}

Вот скриншот нашей обновленной блестящей системы Niagara, использующей наши пользовательские NDI и функции-геттеры!

Большая сложность в преобразовании координат из PLY в Unreal

В настоящее время на просторах интернета практически нет документации, в которой бы явно указывались преобразования, необходимые для преобразования данных, поступающих из PLY-файла в Unreal Engine. 

Вот несколько забавных болезненных неудач проб, через которые нам пришлось пройти, прежде чем мы нашли нужные конверсии.

image6.png
image5.png
image2.png
image9.png
image4.png

После долгих проб и математических расчетов мы наконец-то смогли установить правильное преобразование. Ниже приведен список операций, которые необходимо выполнить:

Position (x, y, z) from PLY 
Position in UE = (x, -z, -y) * 100.0f

Scale (x, y, z) from PLY
Scale in UE = (1/1+exp(-x), 1/1+exp(-y), 1/1+exp(-z)) * 100.0f

Orientation (w, x, y, z) from PLY
Orientation in UE = normalized(x, y, z, w)

Opacity (x) from PLY
Opacity in UE = 1 / 1 + exp(-x)

Чтобы сохранить оптимальную производительность, эти преобразования выполняются при загрузке, а не во время выполнения, так что, как только 3D-гауссианы оказываются в сцене, покадровое обновление не требуется.

Вот как получившийся Gaussian Splatting вьюер покажет ply-файл biker от Уилла Исткотта после расчетов в результате процесса, который я описал в этой статье.

Есть еще несколько фрагментов кода для дальнейших геометрических преобразований и обрезки, но они уже водят за рамками этой статьи.

Заключение и обратная связь

Это было очень долгое путешествие, в результате которого получилась очень длинная статья. Но я надеюсь, что она вдохновила вас лучше понять, как Niagara в Unreal может быть настроена для интерпретации ваших пользовательских данных; как можно оптимизировать ее производительность с помощью шейдерного HLSL-кода на базе GPU, вводимого из ваших пользовательских Niagara Data Interface и Niagara Data Interface Proxy; и, наконец, увидеть гауссов сплэтинг во вьюпорте после всей этой тяжелой работы!

Успевайте присоединиться к открытому уроку на тему «Создаём стратегию в реальном времени на Unreal Engine 5. Реализация управления AI-персонажами и их поведение», который начнется 17 октября в 20:00. Записаться можно на странице курса "Unreal Engine Game Developer. Basic".

Комментарии (0)