Воксели в браузере и обратно: пайплайн.vox‑файлов в Unity WebGL

Привет, меня зовут Кирилл, я занимаюсь Unity разработкой немногим более 6 лет, в этом году мне попалась довольно интересная задача, и я решил поделиться ей с Вами. Это мой первый сабж, так что критика приветствуется, ведь никто не совершенен)
В рамках работы над одним проектом, пришла идея (иногда такое случается) поделиться гайдом для инди и всех заинтересованных — как же импортировать объекты, сделанные в графическом дизайнере MagicaVoxel (vox‑файлы) в свой проект. Эта статья будет об импорте одиночных файлов, возможно в следующей я расскажу как импортировать сложные файлы (из нескольких объектов со слоями и тому подобное).
Статью я писал с акцентом на WebGl платформу и стек в виде Unity 6 + VContainer.
Итак, изначально, за основу я взял ассет PicaVoxel, функционально он нам не совсем подходил (был избыточен), да и в целом очень старый ассет (аж 2015 год). Поэтому оттуда я позаимствовал подход по чтению vox файла, но об этом чуть позже. А, пока что, напишем импортер для нашего проекта.
JS‑пикер
Для начала сделаем jslib для взаимодействия с файловой системой. В целом, для MacOs сборок с их защитой, потребуются небольшие танцы с бубном, подложными кнопками и тому подобное, поэтому эту тему я пока опущу, но думаю тут будет понятен основной посыл. Сам метод внутри выглядит так (не забудьте про mergeInto):
OpenFileInputJS: function (goPtr, methodPtr, cancelMethodPtr) {
var go = UTF8ToString(goPtr);
var method = UTF8ToString(methodPtr);
var cancelMethod = UTF8ToString(cancelMethodPtr);
var input = document.createElement("input");
input.type = "file";
input.accept = ".vox";
input.multiple = true;
var handled = false;
function cleanup() {
if (input.parentNode) {
input.parentNode.removeChild(input);
}
}
input.addEventListener("cancel", function () {
if (handled) return;
handled = true;
SendMessage(go, cancelMethod, "");
cleanup();
});
input.onchange = function () {
if (handled) return;
handled = true;
var files = input.files;
if (!files || files.length === 0) {
SendMessage(go, cancelMethod, "");
cleanup();
return;
}
for (var i = 0; i < files.length; i++) {
let file = files[i];
var reader = new FileReader();
reader.onload = function (e) {
var dataUrl = e.target.result;
var base64 = dataUrl.split(",")[1];
var payload = JSON.stringify({
filename: file.name,
data: base64,
});
SendMessage(go, method, payload);
};
reader.readAsDataURL(file);
}
cleanup();
};
document.body.appendChild(input);
input.click();
},По факту мы позволяем выбрать N файлов разрешения .vox и импортировать их в проект (отправляем в Unity через SendMessage каждый файл). В случае закрытия проекта отправляем соответствующее событие (для всяких там внутренних анимаций сокрытия окон, и прочего внутреннего функционала).
Сервисы
Со стороны c# это выглядит следующим образом (не беру всякие там препроцессорные обертки типа #if UNITY_WEBGL, но они подразумеваются)
public void LoadData()
{
OpenFileInputJS(_bridge.name, nameof(_bridge.OnFileRead), nameof(_bridge.OnFileCanceled));
}
[DllImport("__Internal")]
private static extern void OpenFileInputJS(string go, string method,string cancelMethod);В контейнере у нас зарегистрирован MonoBehaviour объект сцены (bridge), который ловит все месседжи от браузера (jslib), а, так же, там зарегистрирован сервис импорта (в конструктор которого прокинут этот самый бридж через Inject), который создает у себя:
инстанс WebGlFileLoader, который взаимодействует с bridge и наружу отдает нам 2 эвента (OnDataReceived, OnCancelled)
инстанс конвертера из vox в наш тип
Регистрируем наш сервис в ProjectScope, обязательно с параметром, тогда он будет проброшен в конструктор при создании экземпляра:
[SerializeField] private WebGlBridge _webGlBridge;
protected override void Configure(IContainerBuilder builder){
builder.Register<ImportDataService>(Lifetime.Singleton).WithParameter(_webGlBridge)
.AsImplementedInterfaces().AsSelf();
}ImportDataService
[Inject]
public ImportDataService(WebGlBridge webGlBridge)
{
dataImporter = new MagicaDataImporter();
fileDataLoader = new WebGlFileDataLoader(webGlBridge);
fileDataLoader.OnDataReceived += OnFileDataLoadedHandler;
fileDataLoader.OnCancelled += OnFileDataCancelHandler;
}Очередь импорта
Как только файл загрузился — сервис импорта ловит событие, в котором передается filename и массив байтов. Так как файлы у нас грузятся пачкой — внутри нашего сервиса есть очередь, каждый OnDataRecieved — кладет полученные данные в очередь, и запускает ее (если она еще не запущена). Метод, обрабатывающий очередь, отправляет объекты в MagicaDataImporter по одному, каждый раз ожидая события завершения обработки данных. Как только такое событие пришло — цикл повторяется, пока очередь не опустеет.
private void OnFileDataLoadedHandler(byte[] obj, string fileName)
{
_importQueue.Enqueue((obj, fileName));
if (_isProcessingQueue)
return;
isProcessingQueue = true;
batchHadSuccess = false;
ProcessNextInQueue();
}
private void ProcessNextInQueue()
{
if (_importQueue.Count == 0 || cts.IsCancellationRequested)
{
isProcessingQueue = false;
OnDataImportComplete?.Invoke(_batchHadSuccess);
return;
}
var (data, fileName) = importQueue.Dequeue();
dataImporter.Import(data, fileName, OnDataImported, OnSingleFileImportComplete, _cts);
}
private void OnSingleFileImportComplete(bool success)
{
if (success)
{
_batchHadSuccess = true;
}
ProcessNextInQueue();
}VOX‑парсинг
Теперь рассмотрим сам импортер на примере обычного (не составного) файла. Vox‑файл внутри разбит на заголовки.
Первые 4 байта — это сигнатура «VOX » (V, O, X, пробел). Её проверка — самостоятельная и более надежная защита от того, что пользователь (случайно или специально) подсунул не тот файл под.vox расширением.
Далее начинаем в цикле читать поток с текущей позиции до самого конца. Этот набор данных состоит из повторяющихся элементов: заголовок [12 байт] → payload → [дети если есть]. Вся обработка ниже выполняется в корутине, дабы не вешать основной процесс.
while (stream.BaseStream.Position < stream.BaseStream.Length)
{
char id0 = stream.ReadChar();
char id1 = stream.ReadChar();
char id2 = stream.ReadChar();
char id3 = stream.ReadChar(); //4*1 для ASCII
int payloadSize = stream.ReadInt32(); //4
int childrenSize = stream.ReadInt32();//4
bool isSIZE = id0 == 'S' && id1 == 'I' && id2 == 'Z' && id3 == 'E';
bool isXYZI = id0 == 'X' && id1 == 'Y' && id2 == 'Z' && id3 == 'I';
bool isRGBA = id0 == 'R' && id1 == 'G' && id2 == 'B' && id3 == 'A';Первые 4 байта(id0...id3) — название заголовка, вторые 4 байта(payloadSize) — размер payload в байтах, третьи 4 байта (childrenSize) — поле одно и то же для всех чанков, но реально ненулевым оно бывает только у чанков‑контейнеров — у листовых оно всегда 0.
Первый заголовок — MAIN, у него нет payload, поэтому payloadSize будет 0, а childrenSize будет равен размеру всего что лежит после заголовка.
Далее идет SIZE, payloadSize тут будет 12 (4 байта на координату), но гипотетически может случиться так что он будет больше (например другая версия), и чтоб не посыпалось чтение — закладываем тут небольшую защиту. Payload тут занимает 12 байт. В этом блоке мы можем определить размер нашего объекта в вокселях.
if (isSIZE)
{
sizex = stream.ReadInt32(); //4
sizez = stream.ReadInt32(); //4
sizey = stream.ReadInt32(); //4
if (sizex > 32 || sizey > 32 || sizez > 32)
{
_notifierService.Show("Ошибка импорта файла", "Размер объекта превышает 32×32×32 вокселя");
onDataImportComplete?.Invoke(false);
yield break;
}
//защита, т.е. если мы прочитали 12 байт, но размер payload больше - переставляем каретку в конец (в начало следующего блока)
int rest = payloadSize - 12;
if (rest > 0)
stream.ReadBytes(rest);
}Следующим заголовком идет XYZI, тут payload будет равен 4 байта (на размер массива) + 4 байта на воксель количество вокселей. Т.е. Если у нас объект из 813 вокселей — то payload составит 4 + 8134 = 3256 байт.
В рамках этого блока считывем размер массива, после чего проходимся по всему массиву вокселей (обрабатывая их блоками по 4096 штук) и записываем их массив уже с готовыми данными.
else if (isXYZI)
{
int numVoxels = stream.ReadInt32();
voxelData = new MagicaVoxelData[numVoxels];
const int CHUNK = 4096;
int processed = 0;
while (processed < numVoxels)
{
int count = Mathf.Min(CHUNK, numVoxels - processed);
for (int i = 0; i < count; i++)
voxelData[processed + i] = new MagicaVoxelData(stream);
processed += count;
yield return null;
}
}Структура самого вокселя (как единицы данных). В color записан индекс цвета в палитре.
private struct MagicaVoxelData
{
public readonly byte x;
public readonly byte y;
public readonly byte z;
public readonly byte color;
public MagicaVoxelData(BinaryReader stream)
{
x = stream.ReadByte();
z = stream.ReadByte();
y = stream.ReadByte();
color = stream.ReadByte();
}
}Последним заголовком у нас идет RGBA — палитра цветов данного объекта, payload у нее будет 1024 (256 цветов по 4 байта). Тут стоит обратить особое внимание, что если использовалась стандартная палитра из MagicaVoxel, то RGBA‑заголовка в файле не будет — и индекс цвета в данных вокселя нужно резолвить по стандартной (дефолтной) палитре MagicaVoxel, поэтому на этапе создания экземпляра импортера, я рекомендую создать дефолтную палитру, а уже распаковывая vox файл, при наличии RGBA заголовка — просто подменить цвета в палитре на данные из файла.
//в начале метода импорта
Color32[] palette = new Color32[256];
Array.Copy(DefaultPalette, palette, 256);
//ниже в коде при парсинге файла, после блока XYZI
else if (isRGBA)
{
for (int i = 0; i < 256; i++)
{
byte r = stream.ReadByte();
byte g = stream.ReadByte();
byte b = stream.ReadByte();
byte a = stream.ReadByte();
palette[i] = new Color32(r, g, b, a);
}
}
else
{
if (payloadSize > 0)
stream.ReadBytes(payloadSize);
}Если у нас нет обработки других заголовков (но они присутствуют в файле) — скипаем их через их payloadSize
Имея эти данные, мы можем на их основе начать собирать собственный воксельный объект. Создаем новый объект с дефолтными параметрами, и заполняем его данными из нашего массива вокселей.
Пример сборки объекта
ObjectData od = new()
{
Id = Guid.NewGuid(),
Name = fileName,
PivotX = 0,
PivotY = 0,
PivotZ = 0,
Voxels = new VoxelData[voxelData.Length]
};Так как в рамках разработки используется система координат с отрицательными значениями а MagicaVoxel хранит координаты в положительном виде [0...N], сначала определяем смещение вокселей по необходимым осям, затем проходимся по всему массив данных и переносим их в наш объект
int dimensionX = sizex >> 1;
int dimensionZ = sizez >> 1;
const int BUILD_CHUNK = 4096;
for (int index = 0; index < voxelData.Length; index++)
{
MagicaVoxelData v = voxelData[index];
od.Voxels[index] = new VoxelData
{
PackedPos = PositionPacker.PackPos(v.x - dimensionX, v.y, v.z - dimensionZ),
Color = palette[v.color - 1]
};
if (index % BUILD_CHUNK == 0)
yield return null;
}Тут стоит пояснить, что PositionPacker упаковывает позицию вокселя для оптимизации хранения данных внутри Unity в uint (4 байта). В итоге получается что на один объект выходит порядка 9 байт на воксель (это тип вокселя (byte), цвет (Color32) и позиция (uint)). Данный подход позволяет в последствии сохранять воксели в виде бинарников или отправлять на сервер с любым цветом и типом.
Выражаю благодарность всем дочитавшим до этого места)
В планах:
— статья с упаковкой всего этого непотребства и отправкой на сервер;
— показать как можно работать со сложными.vox‑файлами.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.