После того, как Walt Disney Animation Studios выложила в сеть описание сцены острова из «Моаны», много кто пытался его отрендерить своими силами, исключающими оригинальный Hyperion. Это лишь малая часть списка таких движков:
Hyperion (естественно);
PBRT;
A hobby renderer от Джо Шютте (с использованием Embree);
Moana на RTX (с использованием Optix);
GPU-Mononui (с использованием Optix).
Андреас Вендледер из Бабельсбергского киноуниверситета представил другой, написанный им рендерер Gonzales. Он в значительной степени вдохновлен PBRT, написан на Swift (с несколькими строками кода на C ++ для вызова OpenEXR и Ptex) и оптимизирован для проведения рендеринга в (сравнительно) разумные сроки на бесплатном хранилище Google Cloud (8 виртуальных ЦП, 64 ГБ RAM). Насколько автору известно, это единственный рендерер, написанный не на C/C++, способный на рендеринг этой сцены. Написан он с помощью vi и командной строки Swift в Ubuntu Linux и Xcode на macOS, так что скомпилировать его на этих платформах не должно составить труда.
Так почему именно Swift?
Как пишет Вендледер, ему всегда было неудобно работать с заголовочными файлами и препроцессором на C и C++. Однажды объявив и определив что-либо (переменную, функцию), нет нужды делать это повторно. Кроме того, текстуальное включение заголовочных файлов приносит с собой множество проблем, таких как необходимость добавления деталей реализации в эти самые файлы (на ум приходят, например, шаблоны) или медленное время компиляции ввиду многократного включения заголовков и вытекающего отсюда комбинаторного взрыва. Когда он начинал работать на C++, модули там еще не были доступны, поэтому он перепробовал написание кода на Python (слишком медленный), Go (слишком похож на C) и некоторых других языках программирования, но в конце концов остановился на Rust и Swift. В результате предпочтение отдал Swift из-за его удобочитаемости (просто не нравились «fn main» и «impl trait»). Тем более, тот факт, что он был написан разработчиками LLVM и Clang, вселил уверенность, что он а) не будет заброшен в будущем и б) будет соответствовать поставленным целям по производительности. Короче говоря, был нужен скомпилированный язык без указателей, модулей, концепций, диапазонов, читаемых шаблонов, и нужен он был прямо сейчас. Кроме того, компиляторы были изобретены, чтобы облегчить жизнь программистам, сделав программы более читабельными, и порой, смотря на код, основанный на шаблонах, у автора складывалось ощущение, что мы движемся назад во времени. Все любят, когда их код легко читается.
Кое-какие заметки
Парсинг пережил несколько воплощений. Сначала то была простая строка (file.availableData, кодировка: .utf8), но она оказалась слишком велика, чтобы поместиться в памяти. Данные здесь не использовались по аналогичным причинам. В то же время еще и Scanner переехал из Foundation. В конце концов Вендледер остановился на чтении InputStream в 64-КБ массиве UnsafeMutablePointer <UInt8>.
Массивы — всё. Никогда не используйте их при срочной необходимости. То есть, вообще не создавайте их. Это должно было быть ясно с самого начала, но урок был извлечен быстро, поскольку они всегда появлялись в верхней части анализа, выполненного с помощью perf. Для массивов фиксированного размера это можно преодолеть с помощью кортежей или внутреннего FixedArray в Swift. Даже если используется только массив, геттеры индекса, как правило, появляются в начале выполнения perf.
В целом, показалась довольно практичной параллельная разработка на Linux и macOS, поскольку доступные инструменты для проверки производительности и памяти на них прекрасно дополняют друг друга. В основном использовались следующие инструменты:
Perf: этот инструмент Linux дает вам ценную информацию о том, на что тратится время. Просто запустите его, посмотрите на функцию, отображаемую вверху, и подумайте, что здесь вообще происходит. Подсказка: обычно не то, что подразумевалось. В нашем случае это всегда был либо swiftretain, либо release, который снова и снова говорил не выделять объекты в куче (heap).
Valgrind Memcheck: показывает, куда пропала память. Например, анализ с помощью этого инструмента говорит о том, почему структура ускорения отделена от билдера этой структуры: память, потраченная на построение ограничивающей иерархии, просто никогда не освобождалась. Приятно не иметь указателей в Swift: никаких malloc или new и даже sharedpointers, — но все же необходимо подумать о том, как используется память.
Профилирование с Xcode: в основном здесь использовались Time Profiler, Leaks и Allocations, которые дают примерно ту же информацию, что и Perf и Valgrind, но с другой точки зрения. Порой бывает полезно взглянуть на одно и то же с двух разных ракурсов. Это напоминает старые добрые времена, когда мы загружали наше ПО в три разных компилятора (Visual Studio, GCC и один из IRIX, как там его? MIPSPro?).
Хотя Swift позволяет легко писать читаемый и компактный код, вам все равно придется задуматься о низкоуровневых операциях, таких как выделение памяти и тому подобное. Сам автор часто переключался между структурами и классами, чтобы посмотреть, как это повлияет на память и производительность. Отсутствие указателей, new и shared_pointers приятно тем, что большую часть времени можно просто переключаться между уже упомянутыми структурами и классами, не меняя ничего в системе.
FlameGraph — инструмент, который использовался не особенно часто, но который дает хорошую визуализацию. С помощью него можно увидеть одну вещь: что большая часть времени тратится на тестирование пересечений для ограничивающих иерархий и треугольников. Такие же вещи, как проверка Protocol Witness, длятся весьма недолго.
О протокольно-ориентированном программировании: текущая версия Gonzales показывает 23 протокола, 57 структур, 47 заключительных классов и 2 незавершенных класса. Наследование здесь почти не используется. Два оставшихся незавершенных класса — это TrowbridgeReitzDistribution и Texture: оба они самому Вендледеру не нравятся, и он думает о том, как изменить их в будущем. В целом протокольно-ориентированное программирование приводит к хорошему коду: например, раньше в Gonzalez был класс Primitive, такой как в PBRT, но вскоре он сменился на протокол, унаследованный от таких протоколов, как Boundable, Intersectable, Emitting (его уже нет) и других. Теперь и его тоже нет: BoundingHierarchyBuild просто зависит от экзистенциального типа Boundable и возвращает иерархию Intersectables, которую использует BoundingHierarchy. Все примитивы теперь хранятся как массив экзистенциальных типов, состоящий из композиции протоколов Boundable и Intersectable (var primitives = Boundable&Intersectable).
С другой стороны, примитивы в BoundingHierarchy хранятся как [AnyObject&Intersectable]. На это есть две причины:
1. Требуются только пересечения;
2. AnyObject заставляет хранимые объекты быть ссылочными типами (или классами), что экономит память, поскольку макет протоколов как для структур, так и для классов (OpaqueExistentialContainer) использует 40 байтов — ведь Swift пытается хранить встроенные структуры, тогда как протоколы только для классов (ClassExistentialContainer) используют всего 16 байт, ведь там должен храниться только указатель, как это можно увидеть в документации Swift или проверить здесь. Стоит подчеркнуть, что это не только академическое обсуждение: автор столкнулся с этим лично в ходе работы.
Одна из причин, по которой вы можете отрендерить этот остров менее чем за 10 000 строк — возможность писать компактный код на Swift. Один из таких примеров — списки параметров. В PBRT вы можете прикреплять произвольные параметры к объектам, что приводит к примерно 1000 строкам кода в paramset.[h|cpp]. В Swift вы можете добиться того же примерно за три строчки:
protocol Parameter {}
extension Array: Parameter {}
typealias ParameterDictionary = Dictionary<String, Parameter>
На самом деле, это, конечно, не совсем так, но вы поняли суть. (Кроме того, скорее всего, это изменилось бы в PBRT-v4.)
О взаимодействии C ++ для поддержки Ptex и OpenEXR: Интегрирование с C ++ для Swift уже на подходе, но оно было недоступно, когда Вендледер только начинал со всем этим заниматься, и все еще недоступно на данный момент. Поскольку автор использует OpenEXR и Ptex только для чтения текстур и записи изображений, он прибег еще к extern "C". Одна карта модулей и несколько строк кода на C ++ (100 для Ptex, 82 для OpenEXR) — и вас уже есть поддержка чтения и записи изображений OpenEXR и текстур Ptex.
Этот код публикуется сейчас потому, что получилось добиться его работы на Google Compute Engine с 8 виртуальными ЦП и 64 ГБ памяти, бесплатной в течение трех месяцев, — поэтому, пожалуйста, загрузите код и заведите учетную запись, чтобы запустить его. Тем не менее, предстоит еще многое сделать, поскольку сейчас он оптимизирован только для получения одного изображения. Ниже приводится большой список задач, отсортированный от легко реализуемых до крупных проектов, за которые автор мог бы или не мог бы взяться в будущем.
Список дел
Дифференциалы лучей прямого освещения. Это должно быть относительно просто. Посмотреть, как это делает PBRT-v3, реализовать дифференциальную генерацию в камере, прокачать ее через систему и использовать в вызове Ptex. Там все это обрабатывается автоматически.
Лучшие иерархии: в Gonzalez реализована только простейшая ограничивающая иерархия, что приятно, поскольку она занимает всего 177 строк кода, но в то же время это приводит к неоптимальному времени рендеринга. Оптимизированные иерархии SAH должны показывать себя намного лучше в этом плане. Их также не должно быть сложно реализовать.
Ускоренный парсинг: нужно встроить быстрый парсер pbrt Инго Вальда, который анализировал бы «Моану» за секунды, а не за полчаса. Или даже лучше: написать самому парсер для формата pbf на Swift.
Ускоренное создание иерархии: сейчас оно происходит медленнее, чем хотелось бы. Что с этим можно сделать?
Идея об ускоренном парсинге, генерации иерархии и форматах сцены. LLVM имеет три различных формата битового кода: in-memory, machine readable (двоичный) и human readable, — и он может без потерь преобразовывать их между собой. Может ли у нас происходить то же самое? Подобно PBRT (читаемому человеком), PBF или USD (машиночитаемому) и BHF (формату двоичной иерархии), где ограничивающие иерархии уже созданы и могут быть помещены в память.
Задачка начального уровня: сам автор пытался отрендерить только «Моану», но должно оказаться довольно легко улучшить Gonzales так, чтобы он мог рендерить и другие сцены. Существует множество сцен, на которых можно потренироваться. Также есть много экспортеров для PBRT, которые должны работать и для Gonzales.
Bump mapping: должно оказаться довольно просто.
Displacement mapping: не так просто.
Память: для хранения пикселей используется много места, поскольку изображение записывается только после завершения рендеринга. Стоит это переписать, чтобы записывать тайлы по мере их рендеринга и отбрасывать пиксели раньше. Это мешает фильтрации пикселей, но поскольку мы все равно подавляем шум, может быть, это и не нужно?
Меньше преобразований. На данный момент преобразования содержат две матрицы: матрицу 4?4, хранящую преобразование, и обратную ей. Это немного расточительно, ведь вы всегда можете вычислить одно из другого, хотя инверсия и выполняется медленно, — однако после тщательного обдумывания, когда какое преобразование необходимо, можно будет избавиться от лишнего. Прямо сейчас обе матрицы используются при пересечении треугольников, но можно ли сохранить треугольник (и другие объекты, такие как кривые) в мировом пространстве, чтобы избавиться от преобразования луча в пространство объектов и, аналогичным образом, преобразования в мировое пространство для взаимодействий на поверхности? И как это взаимодействует с преобразованными примитивами и экземплярами объектов?
Шумоподавление: сейчас используется OpenImageDenoise, но, конечно, было бы неплохо иметь встроенный шумоподавитель в Swift. Кроме того, beauty, albedo и normal image сейчас пишутся отдельно, и это нужно переделать.
USD: написать парсер для Universal Scene Description Pixar.
Лучший сэмплинг: использовать discrepany-based sampling или multi-jittered sampling.
Трассировка пути: изучить PxrUnified и реализовать управляемую трассировку пути (автор уже смотрел ее, но выглядит она… запутанно) и Manifold Next Event Estimation. Кажется, где-то была такая реализация, но уже забыл, где.
Подповерхностное рассеяние. Уже в PBRT.
Ускоренный рендеринг: у Embree есть трассировщик пути. Изучить его внимательно и постараться сделать Gonzales быстрее.
Рендеринг на ГП: достаточно объемная задача, и PBRT-v4, очевидно, делает это так же, как некоторые из упомянутых выше рендереров. Возможно, стоит просто сделать так же, как они, и использовать Optix для рендеринга на видеокарте, но лучше бы найти решение, не использующее закрытый исходный код. Это означало бы, что вам нужно реализовать свой собственный Optix. Но если посмотреть на то, как развиваются ГП и ЦП, в далеком будущем вполне возможно использовать один и тот же (Swift) код на них обоих; у вас могут быть экземпляры с 448 ЦП в облаке, а новейшие графические процессоры имеют несколько тысяч микро-ЦП, и они выглядят все более и более одинаково. Интересно, понадобится ли программирование для AVX в будущем, поскольку сейчас оно кажется все менее необходимым, ведь вы можете просто добавить больше ядер для решения проблемы. В то же время память становится все больше и больше похожей на NUMA, так что расположение ваших данных рядом с ALU становится все более важным. Может быть, однажды у нас появятся узлы рендеринга в облаке, каждый из которых будет отвечать за свою часть сцены: геометрически разбивать эту сцену и отправлять только части ее в ЦП. Затем возвращенные пересечения можно было бы просто отсортировать по значению t луча, что напоминает архитектуры сортировки по первому/среднему/последнему, такие как Chromium.
На этом пока все. Автор был бы очень рад получить комментарии о том, что можно было бы сделать лучше или более элегантно, отчеты об ошибках или даже пулл-реквесты. Также спасибо Мэтту Фарру и PBRT — наиболее ценному ресурсу в известной вселенной (по крайней мере, касательно рендеринга).