В этом руководстве я расскажу, как использовать compute shader для реализации вычислений на видеокарте — на примере модели волос:
Вот проект для Unity3D, на объяснении работы которого построено руководство. Его нужно скачать и открыть в Юнити:
ссылка на проект юнити
Кому это руководство будет понятно? Тем, кто использует Unity3D или по крайней мере знает C# или C++. Шейдер написан на HLSL, близком синтаксическом родственнике C++.
Кому это руководство будет полезно? Опытным программистам, желающим научиться использовать GPU для вычислений. Но даже неопытный, но прилежный программист легко всё поймёт.
А зачем использовать видеокарту для вычислений? Для параллельных задач её производительность в 10-100 раз выше, чем у процессора. То есть, у каждого в компьютере есть небольшой суперкомпьютер с удобным API, есть смысл его использовать в подходящих случаях.
Эта огромная производительность действительно нужна? Да, частенько скорость процессора — ограничивающий фактор. Например, когда надо производить одинаковые операции над большими массивами данных. А ведь именно такие задачи легко параллелизируется. Кроме того, часто разработчики отказываются от решений из-за их вычислительной ёмкости, и целые области в пространстве алгоритмов остаются неисследованными. Например, можно делать крутейшую физику в играх, если хорошенько нагрузить графический процессор.
А что, с видеокартой теперь можно просто решать задачи грубой силой? Востребованность оптимизации не зависит от производительности железа. Нет такого суперкомпьютера, который нельзя было бы наглухо загрузить неэффективным кодом.
Почему именно compute shader? Почему не opencl или cuda? Cuda работает только на nvidia-железе, а opencl я не знаю. Юнити может билдить в любое API, включая opengl core. На маках и на андроиде компьют шейдеры работают, на линуксе вроде тоже (хотя я не пробовал). Хотя, у каждого API есть ограничения, которые следует учитывать. Например, на Metal нельзя делать больше 256 потоков вдоль одной оси (В DX10 — 1024). А андроидное API не сможет использовать больше 4 буфферов на kernel (В DX10 — 8, в DX11 — ещё больше).
Почему именно физическая симуляция? Это вычислительноёмкая задача, при этом хорошо подходящая для параллельного вычисления. Кроме того, задача востребованная. Геймдевы могут в играх реализовывать интересную физику, студенты могут создавать экспериментальные модели для курсовых, инженеры и учёные — делать расчёт на модели.
А почему именно модель волос? Я хотел взять простую задачку, но при этом покрывающую основную проблематику.
Как пользоваться этим руководством? Лучше всего скачать исходный код, открыть его и читать по мере продвижения по руководству. Я подробно объясню все основные строки, хотя не буду объяснять совсем каждую строчку, смысл большинства из них очевиден. Никаких сложных алгоритмов в тексте нет, есть только использование интерфейса классов, обслуживающих вычисления на GPU. А на стороне шейдерного кода нет ничего, кроме считывания данных, осуществления над ними простых математиеских операций и записи результатов. Но если что-то будет непонятно — непремнно спрашивайте, на всё отвечу в каментах.
А теперь тем, кто не имеет абсолютно никакого представления об использовании compute shader-ов, я предлагаю сделать шаг в сторону и перейти к очень простому руководству, которое посвещено азам использования компьют шейдеров. Я советую начать с него чтобы лучше уяснить суть и приноровиться к практике GPU-вычислений на предельно простом примере. А потом вернётесь сюда и продолжите. А те, кто с компьют шейдерами хоть как-то знаком, пусть смело читают дальше.
Если вы с нуля хотите сделать вычисляемую на GPU физическу модель, то эту задачу можно разделить на 4 части:
— математическая модель явления
— алгоритм для параллельного вычисления модели
— код шейдера
— подготовка и запуск шейдера в юнити
Сильная сторона видеокарт в том, что они могут применить одну операцию одновременно ко множеству объектов. Поэтому, модель волоса можно сделать как цепь точек, каждая из которых взаимодействует с двумя соседями. Взаимодействие между точками — по принципу пружины: k * (S0-S)^n, где S0 — дистанция равновесия, S — текущая дистанция. В реальности волос не похож на пружину, он воспринимается нерастягиваемым. Значит, пружину в модели надо сделать достаточно жёсткой. Повышать жёсткость пружины лучше повышая n, потому что степень увеличивает кривизну кривой в окрестности равновесия, что уменьшает люфт и снижает эффект «резиновости» волоса. Я взял n = 2, а о величине коэффициента k поговорим ниже.
Кроме силы упругости между точками будет реализована диффузия относительных скоростей или одномерная вязкость. Обмен тангенциальносй составляющей скорости моделирует динамическое сопротивление растяжению, а обмен нормальной характеристикой скорости — динамичекое сопротивление сгибу. Всё вместе это ускорит передачу возмущений вдоль волоса, что улучшит динамику, сделает волос визуально более связным и менее пружинистым.
Кроме того, будет ещё и статическое стремление к распрямлению. Каждая точка будет стремиться скомпенсировать сгиб волоса. Если в точке будет сгиб, на точку будет действовать сила пропорциональная величине сгиба и направленная в направлении уменьшения величины сгиба. Две соседние с местом сгиба точки будут испытывать вдвое меньшую силу в противоположном направлении.
Этих взаимодействий достаточно, чтобы смоелировать физику волоса, но ею мы не ограничимся. Нужно добавить взаимодействие волоса с твёрдыми объектами. В этом есть практический смысл. Дело не только в том, что физические модели как правило включают взаимодействие между собой разных параллельно моделируемых сущностей, например, жидкости и твёрдых тел. Но и в том, что в практических задачах, например, в играх, GPU-симуляция должна в реальном времени взаимодействовать с объектами, вычисляемыми на стороне CPU. Так что я не мог не уделить внимание такому взаимодействию. У нас волосы будут взаимодействовать с твёрдыми телами, информация о которых будет передаваться в видеопамять в каждом такте.
Для простоты мы будем работать только с круглыми объектами. На стороне CPU у нас будет несколько circle colliders из стандартной 2д-физики юнити. И правило взаимодействия будет такое: если точка волоса окажется внутри твёрдого тела, она переносится наружу, а из скорости такой точки вычитается фракция, направленная в сторону тела, и эта же фракция передаётся телу. Абсолютную скорость тела мы учитывать не будем, для простоты.
Эти три пункта слишком сильно связаны, чтобы обсуждать их по отдельности.
Для описания точки, из множества которых сделаны волосы, мы используем такую структуру:
Эта структура объявлена дважды: на стороне CPU и на стороне GPU. Для удобства. На стороне CPU мы записываем начальные данные, копируем их в GPU-буффер, и дальше они там обрабатываются. Но можно было объясить эту структуру только на стороне GPU, если нам не требуется передавать начальных данных.
Насчёт параметров dummy1 и dummy2. В статье, написанной инженером из nvidia я прочитал, что данные буфферов видеопамяти лучше держать кратными 128 битам. Поскольку это уменьшает количество операций, необходимых для вычисления смещения.
Значения остальных параметров, полагаю, понятны. Хотя, внимательный читатель может спросить: почему скорость имеет тип float, а изменение скорости — int? Короткий ответ: потому что изменение скорости модифицируется одновременно параллельными потоками, и чтобы избежать ошибок в вычислениях, нужно использовать защищённую запись. А функция защищённой записи работает только с целочисленными переменными. Подробней я расскажу об этом ниже.
Точек, которыми мы моделируем волосы, у нас много. Данные обо всех точках хранятся в видеопамяти и доступны через интерфейс буффера:
В коде шейдера мы определяем только его имя и тип данных, а его размер задаётся снаружи, со стороны выполняемого на процессоре кода.
Как структурирован код компьют шейдера, что это вообще такое? Код состоит кернелов. Это то же самое, что методы, но выполняется каждый кернел параллельно на множестве ядер. Поэтому, для каждого указывается количество потоков в виде трёхмерной структуры.
Вот так выглядит пустой кернел, в котором нет никакого кода, только необходимая внешняя информацияё:
У кернела есть входной параметр id, который хранит трёхмерный индекс потока. Это очень удобно, каждый поток знает свой индекс, а значит, может работать со своей отдельной единицей данных.
Со стороны процессорного кода кернел вызывается так:
Вот эти три цифры «2, 2, 1» связаны со строкой, предваряющей соответствующий кернел:
Эти две тройки цифр определяют количество потоков, то есть количество параллельных экземпляров кернела. Нужно их просто перемножить: 8 * 4 * 1 * 2 * 2 * 1 = 128 потоков.
Адресация потоков будет своя по каждой оси. В данном случае по оси x будет 8 * 2 = 16 единиц. По оси у 4 * 2 = 8 единиц. То есть, если кернел вызывается так:
А на стороне шейдера число потоков задано так:
То у нас будет (X * x) * (Y * y) * (Z * z) потоков
Для примера предположим, что нам надо обработать каждый пиксел текстуры размером 256 x 256, и мы хотим чтобы каждым пикселом занимался отдельный поток. Значит, можем определить количество потоков так:
и на стороне шейдера:
Внутри кернела параметр id.x примет величины в диапазоне [0, 255], то же самое — параметр id.y
А значит, вот такая строка:
окрасит в белый цвет каждый из 65536 пикселов текстуры
id.xy — это то же самое, что uint2(id.x, id.y)
Если эта часть, связанная с количеством потоков, кому-то непонятна, советую пойти в упомянутое мной более лёгкой руководство, и посмотреть, как всё это на практике используется для рисования фрактала Мандельброта посредством простейшего шейдера.
Текст шейдера в рассматриваемой нами модели содержит несколько кернелов, которые по очереди запускаются на стороне CPU в методе Update(). Я потом рассмотрю текст каждого кернела, а сначала кратко объясню, что каждый из них делает.
calc — вычисляются тангенциальная и нормальная силы взаимодействия между частицами: сила натяжения «пружин» толкает частицы вдоль линии между ними, а сила «жёсткости на сгиб» толкает частицы перпендикулярно линии между соседними частицами; рассчитанные величины сил сохраняются для каждой частицы
velShare — частицы обмениваются относительными скоростями. Тангенциальной и полной состоавляющими — по отдельности. Зачем выделять тангенциальную, если потом всё равно идёт обмен полной скоростью? Обмен тангенциальной скоростью должен быть гораздо интенсивней, чем нормальной, при ней должен быть коэффициент повыше, так что её надо было выделить. Тогда зачем во втором случае я не использую чистую нормальную составляющую, а использую полную скорость? Чтобы сэкономить на вычислениях. Изменения в скорости записываются в виде сил, Как и в предыдущем кернеле.
interactionWithColliders — каждая точка взаимодействует с коллайдерами, информация о которых содержится в обновляемом в каждом цикле буффере
calcApply — рассчитанные в предыдущих кернелах силы прибавляются к скорости, а скорости изменяют координаты точек
visInternodeLines — между точками рисуются линии в специальном буффере длиной 1024 x 1024 (пока ещё не на текстуре)
pixelsToTexture — а тут величины из упомянутого уже переводятся в цвета пикселей на текстуре размером [1024, 1024]
clearPixels — все величины промежуточного буффера (в котором мы рисовали линии) обнуляются
clearTexture — очищается текстура
oneThreadAction — этот кернел выполняется в одном единственном потоке, он нужен чтобы плавно передвигать всю систему волос туда, куда мы мышкой её перетащили. Плавность нужна чтобы система от резкого перемещения не ушла вразнос (как вы помните, в нашей модели силы между частицами пропорциональны квадрату расстояния между ними).
Теперь я покажу, как эти кернелы запускаются со стороны CPU-кода. Но сначала — о том, как подготовить шейдер к запуску.
Объявляем переменную:
Инициализируем её, указывая файл с текстом шейдера:
Задаём константы, которые нам пригодятся на стороне GPU
Объявляем переменные для массива, который будет хранить данные моделируемых точек, и для буффера, черз интерфейс которого мы сможем читать и писать данные в видеопамять
Инициализируем буффер и записываем данные массива в видеопамять
Для каждого кернела устанавливаем используемые буфферы, чтобы кернел мог читать и писать данные в этот буффер
Когда все необходимые буфферы созданы и установлены для всех кернелов шейдера, можно запускать кернелы.
Все кернелы запускаются из Update(). Из FixedUpdate() их запускать не следует (будет сильно лагать), потому что графический конвейр синхронизирован с Update().
Кернелы запускаются вот в такой последовательности (привожу целиком код вызываемого в Update() метода «doShaderStuff»):
Сразу бросается в глаза, что несколько кернелов запускаются 40 раз за апдейт. Зачем? Чтобы при малом временном шаге симуляция работала быстро в реальном времени. А почему временной шаг должен быть мал? Для уменьшения ошибки дискретизации, то есть для стабильности системы. А как и почему возникает нестабильность? Если шаг большой, и на точку действует большая сила, то за один шаг точка улетает далеко, возвратная сила становится ещё больше, и на следующем шагу точка улетает в другую сторону ещё дальше. Результат: система уходит вразнос, все точки летают туда-сюда с нарастающей амплитудой. А при малом шаге все кривые сил и скоростей очень плавные, потому что погрешности сильно уменьшаются с уменьшением временного шага.
Так что вместо одного большого шага система делает 40 маленьких шагов в каждом цикле, и благодаря этому демонстрирует высокую точность вычслений. Благодаря высокой точности можно работать с большими силами взаимодействия без потери стабильности. А большие силы означают, что у нас не вялые пружинистые макаронины в модели болтаются, норовя взорваться от резкого движения, а бодро вертятся прочные волосики.
Данные о точках, которыми мы моделируем волосы, хранятся в видеопамяти в виде одномерного массива, к которому мы обращаемся через интерфейс буффера.
Для удобства работы с одномерным буффером мы индексируем потоки следующим образом: (ось x: количество волос * ось у: количество точек в волосе). То есть, у нас будет двумерный массив потоков, каждый из которых будет знать свою точку по индексу потока.
Как вы помните, количество потоков, в которых выполняется кернел, определяется произведением параметров метода Dispatch() и параметров директивы [numthreads()] в шейдерном коде.
В нашем случае все кернелы, работающие с точками волос, предварены директивой [numthreads(16,8,1)]. Поэтому, параметры метода Dispatch() должны быть таковы, чтобы произведение давало число потоков не меньшее, чем нам требуется для обработки всего массива точек. В коде мы рассчитываем параметры х и у метода Dispatch():
Взаимоотношение параметров [numthreads()] и Dispatch() проистекает из архитектуры графических вычислителей. Первое — это количество потоков в группе. Второе — это количество групп потоков. Их соотношение влияет на скорость работы. Если нам требуется 1024 потока по оси x, лучше сделать 32 группы по 32 потока, чем 1 группу по 1024 потока. Почему? Для ответа на этот вопрос нужно много рассказать об архитектуре GPU, оставим эту слишком глубокую тему незатронутой.
Итак, 40 раз за апдейт мы запускаем по очереди кернелы, вычисляющие изменение скорости точек и изменяющие их скорости и координаты. Давайте рассмотрим код каждого кернела. Там всё довольно просто, нужно только усвоить пару специфических особенностей.
Кернел «calc» вычисляет изменение скорости точек. Точки в буффере «hairNodesBuffer» расположены по очереди, сначала первая точка первого волоса, потом вторая, и так до последней. Потом сразу первая точка второго волоса, и так далее по всем волосам, до конца буффера. Мы помним, что у кернела есть параметр id, и в нащем случае id.x указывает на номер волоса, а id.y — на номер точки. И вот, как мы получаем доступ к данным точек:
Здесь величина nNodesPerHair — это константа, которую мы задали на стороне CPU при инициализации шейдера. Данные из буффера скопированы в локальные переменные node и node2 потому, что обращение к данным буффера может требовать больше циклов ядра, чем обращение к локальной переменной. Сам алгоритм таков: для каждой точки, если она не последняя в волосе, мы рассчитываем силу, действующую между ней и следующей точкой. На основе этой силы мы записываем изменение скорости в каждую из точек.
Вот важная особенность параллельного вычисления: каждый поток модифицирует две точки, текущую и следующую, а значит, каждую точку модифицируют два параллельных потока. Незащищённая запись в общие для параллельных потоков пременные чреват потерей данных. Если пользоваться обычным инкрементом:
то запись может произойти одновременно, вот таким образом: первый поток скопирует исходное значение, прибавит к нему единицу, но прежде чем он запишет величину обратно в ячейку памяти, второй поток возьмёт исходное значение. Затем первый поток запишет увеличенное на единицу значение обратно. После чего второй поток добавит свою единицу и запишет увеличенное значение обратно. Результат: хотя два потока добавили по единице, переменная увеличилась только на одну единицу. Чтобы избежать этой ситуации, используют защищённую записаь. В HLSL есть несколько функций для защищённой модификации обобщёных переменных. Они гарантируют, что данные не пропадут и учтётся вклад каждого потока.
Небольшая проблема состоит в том, что эти функции работают только с целочисленными переменными. И именно поэтому в структуре, описывающей состояние точки мы используем параметры dvx и dvy типа int. Чтобы была возможность писать в них с помощью защищённых функций и не терять данные. Но для того, чтобы не терять точности на округлении, мы заранее определили множители. Один переводит float в int, другой — обратно. Так мы используем весь дианазон int-величины, и не теряем в точности (теряем, конечно, но пренебрежимо мало).
Защищённая запись выглядит так:
Здесь F_TO_I — упомянутый коэффициент для проекции float на int, dv — вектор силы влияния второй частицы на первую через пружинную связь. А dvFlex — распрямляющая сила. "(int)" нужно добавлять потому, что InterlockedAdd() перегружен для типов int и uint, и float по умолчанию интерпретируется как uint.
Кернел «velShare» похож на предыдущий, в нём тоже модифицируются параметры dvx и dvy двух соседствующих точек, но вместо расчёта сил, рассчитывается диффузия относительной скорости.
В кернеле «interactionWithColliders» точки не взаимодействуют друг с другом, тут каждая точка пробегает по всем коллайдерам буффера твёрдых тел (который мы в каждом апдейте обновляем). То есть каждый поток пишет только в одну частицу, нет опасности одновременной записи, и поэтому вместо InterlockedAdd() мы можем напрямую изменять скорость частицы. Но при этом наша модель подразумевает, что точки передают импульс коллайдеру. Значит, параллельные потоки могут одновременно изменять величину импульса одного и того же коллайдера, а значит, используем защищённый вариант записи.
Только тут нужно понимать: когда мы проецируем float на int, у нас конкурируют целая и дробная части. Точность конкурирует с диапазоном величины. Для случая взаимодействия точек мы выбрали коэффициент, допускающий достаточный для нас разброс величины, а остальное пустили на точность. Но для передачи импульса коллайдеру этот коэффициент не годится, потому что одновременно сотни точек могут добавить свой импульс в одном направлении, и поэтому надо пожертвовать точностью в пользу способности вместить большое число. Так что при защищённой записи мы не используем коэффициент F_TO_I, а используем коэффициент поменьше.
После того, как все взаимодействия точек рассчитаны, мы в кернеле «calcApply» прибавляем импульс к скорости, а скорости к координатам. Кроме того, в этом кернеле каждая корневая (первая по счёту) точка волоса фиксируется в определённом месте относительно текущего положения всей системы волос. Ещё в этом кернеле к вертикальной составляющей скорости прибавляется вклад гравитации. Плюс, реализуется «торможение» о воздух, то есть абсолютная величина скорости каждой точки умножается на коэффициент чуть меньше единицы.
Обратим внимание, что в кернеле «calcApply» скорость влияет на координаты через посредство коэффициента «dPosRate». Он определяет величину шага моделирования. Этот коэффициент задан на стороне CPU и хранится в переменной, которую я так и назвал «simulationSpeed». Чем больше этот параметр, тем быстрей систему будет эволюционировать во времени. Но тем ниже будет точность расчёта. Точность расчёта, повторюсь, ограничивает величину сил, так как при больших силах и низкой точности величина ошибки так велика, что именно она определяет поведение модели. Мы взяли скорость моделировани довольно низкую, это даёт нам большую точность, поэтому мы можем себе позволить большие силы, а значит более реалистичное поведение модели.
За величину сил отвечает коэффициент, связывающий воздействие импульса на скорость — «dVelRate». Этот коэффициент у нас большой, он задан на стороне CPU и называется «strengthOfForces».
Повторюсь, что во всех упомянутых кернелах количество потоков равно количеству точек, один поток отвечает за обработку одной точки. И это хорошая практика. Мы ничего не платим за количество потоков, их может быть сколько угодно (в shader model 5.0 — не больше 1024 по осям x и y и не больше 64 по оси z). В традиции параллельных вычислений лучше избегать использования циклов для выполнения в одном потоке одной операции по отношению к нескольким единицам данных, лучше сделать столько потоков, сколько требуется для реализации принципа «одна единица данных — один поток».
Вернёмся в метод doShaderStuff() на стороне CPU-кода. После выполнения цикла из 40 шагов вычисления модели волос, мы считываем данные коллайдера:
Можно вспомнить, что на стороне GPU в буффер с данными коллайдера записываются импульсы со стороны волос, и их мы используем на стороне CPU для приложения силы к rigidbody. Заметим, что сила к rigidbody прилагается в методе FixedUpdate(), поскольку он синхронизирован с физикой. При этом, данные об импульсе обновляются в Update(). А значит, под воздействием разных факторов, за один Update() может произойти несколько FixedUpdate() и наоборот. То есть, во влиянии волос на коллайдер нет абсолютной точности, часть данных может быть перезаписана прежде, чем оказать влияние, а другие данные могут оказать влияние дважды. Можно принять меры, чтобы этого не происходило, но в рассматриваемой программе этих мер не принято.
Тут стоит ещё отметить, что метод GetData() приостанавливает работу графического конвейра, что вызывает ощутимое замедление работы. Асинхронной версии этого метода в юнити, к сожалению, пока не реализовано, хотя, по слухам, в 2018 году она появится. А пока нужно понимать, что если в вашей задаче необходимо копировать данные из GPU в CPU, программа будет работать на 20-30% медленнее. При этом, метод SetData() такого эффекта не имеет, работает быстро.
Оставшиеся кернелы, запускаемые в методе doShaderStuff(), связаны только с визуализацией системы волос.
Рассмотрим всё, что касается визуализации.
На стороне CPU мы объявляем переменную RenderTexture, не забываем установить enableRandomWrite = true, и используем её в качестве mainTexture в материале UI-компонента Image.
И затем для каждого кернела, который должен писать в эту текстуру, мы вызываем метод SetTexture(), чтобы связать наш объект RenderTexture со переменной на стороне шейдера:
На стороне шейдера у нас объявлена переменная типа RWTexture2D, через посредство которой мы задаём цвета пикселей текстуры:
Теперь рассмотрим кернел очистки текстуры, который вызывается перед записью в неё цветных пикселей:
Запускается этот кернел так:
Мы видим, что у нас 1024 x 1024 потоков, по потоку на пиксель. Что удобно: просто используем параметр id.xy для адресации пиксела.
Как именно рисуются волосы? Я решил сделать волосы полупрозрачными, чтобы при их пересечении цвет был более насыщенным, из чего вытекает необходимость использовать защищённую запись, так как две линии могут рисоваться одновременно на одном пикселе, покольку, как и в уже рассмотренных кернелах, у нас все точки будут выполняться одновременно в количестве потоков, равном количеству точек. Само рисование тривиально: от каждой точки проводим линию к следующей точке. Существуют специальные алгоритмы для выбора множества квадратных пикселей, заметаемых линией, но я решил пойти по простому пути: линия рисуется путём продвижения маленькими шагами вдоль линии между двух точек.
Так как используется инкремент, я пишу данные о цвете в буффер, а не в текстуру. Текстура почему-то не читается, хотя вроде бы должна.
После того, как кернел «visInternodeLines» прочертил все линии, мы копируем пиксели из буффера в текстуру. Я не использовал никаких цветов, рисуются только градации серого. Если бы мне понадобился цвет, то вместо буффера RWStructuredBuffer я использовал бы RWStructuredBuffer или можно было бы запиывать 4 параметра цвета в один uint.
Кстати, этот метод с RenderTexture не работает на маках, и получить ответ на вопрос «почему» на форуме мне не удалось.
Существуют и другие методы визуализации данных из compute shader, но я, признаться, их пока не изучал.
После того, как кернел «pixelsToTexture» модифицировал текстуру, у нас на экране появляется изображения развевающихся волос.
Я рассказал обо всех участках кода, касающихся вычислений на GPU. Информации в руководстве довольно много, и может быть сложно её разом усвоить. Если вы планируете экспериментировать в этой области, я советую с нуля написать простенькую программу, чтобы через практику закрепить знания. Считайте это домашним заданием. Выполнить его будет просто и полезно.
Напишите шейдер с одним кернелом, возводящим в квадрат все числа из большого массива. На стороне CPU подготовьте массив, запишите его в буффер шейдера, запустите кернел, потом получите информацию из видеопамяти и проверьте, возведены ли числа в квадрат.
Вот проект для Unity3D, на объяснении работы которого построено руководство. Его нужно скачать и открыть в Юнити:
ссылка на проект юнити
Кому это руководство будет понятно? Тем, кто использует Unity3D или по крайней мере знает C# или C++. Шейдер написан на HLSL, близком синтаксическом родственнике C++.
Кому это руководство будет полезно? Опытным программистам, желающим научиться использовать GPU для вычислений. Но даже неопытный, но прилежный программист легко всё поймёт.
А зачем использовать видеокарту для вычислений? Для параллельных задач её производительность в 10-100 раз выше, чем у процессора. То есть, у каждого в компьютере есть небольшой суперкомпьютер с удобным API, есть смысл его использовать в подходящих случаях.
Эта огромная производительность действительно нужна? Да, частенько скорость процессора — ограничивающий фактор. Например, когда надо производить одинаковые операции над большими массивами данных. А ведь именно такие задачи легко параллелизируется. Кроме того, часто разработчики отказываются от решений из-за их вычислительной ёмкости, и целые области в пространстве алгоритмов остаются неисследованными. Например, можно делать крутейшую физику в играх, если хорошенько нагрузить графический процессор.
А что, с видеокартой теперь можно просто решать задачи грубой силой? Востребованность оптимизации не зависит от производительности железа. Нет такого суперкомпьютера, который нельзя было бы наглухо загрузить неэффективным кодом.
Почему именно compute shader? Почему не opencl или cuda? Cuda работает только на nvidia-железе, а opencl я не знаю. Юнити может билдить в любое API, включая opengl core. На маках и на андроиде компьют шейдеры работают, на линуксе вроде тоже (хотя я не пробовал). Хотя, у каждого API есть ограничения, которые следует учитывать. Например, на Metal нельзя делать больше 256 потоков вдоль одной оси (В DX10 — 1024). А андроидное API не сможет использовать больше 4 буфферов на kernel (В DX10 — 8, в DX11 — ещё больше).
Почему именно физическая симуляция? Это вычислительноёмкая задача, при этом хорошо подходящая для параллельного вычисления. Кроме того, задача востребованная. Геймдевы могут в играх реализовывать интересную физику, студенты могут создавать экспериментальные модели для курсовых, инженеры и учёные — делать расчёт на модели.
А почему именно модель волос? Я хотел взять простую задачку, но при этом покрывающую основную проблематику.
Как пользоваться этим руководством? Лучше всего скачать исходный код, открыть его и читать по мере продвижения по руководству. Я подробно объясню все основные строки, хотя не буду объяснять совсем каждую строчку, смысл большинства из них очевиден. Никаких сложных алгоритмов в тексте нет, есть только использование интерфейса классов, обслуживающих вычисления на GPU. А на стороне шейдерного кода нет ничего, кроме считывания данных, осуществления над ними простых математиеских операций и записи результатов. Но если что-то будет непонятно — непремнно спрашивайте, на всё отвечу в каментах.
А теперь тем, кто не имеет абсолютно никакого представления об использовании compute shader-ов, я предлагаю сделать шаг в сторону и перейти к очень простому руководству, которое посвещено азам использования компьют шейдеров. Я советую начать с него чтобы лучше уяснить суть и приноровиться к практике GPU-вычислений на предельно простом примере. А потом вернётесь сюда и продолжите. А те, кто с компьют шейдерами хоть как-то знаком, пусть смело читают дальше.
Если вы с нуля хотите сделать вычисляемую на GPU физическу модель, то эту задачу можно разделить на 4 части:
— математическая модель явления
— алгоритм для параллельного вычисления модели
— код шейдера
— подготовка и запуск шейдера в юнити
Математическая модель
Сильная сторона видеокарт в том, что они могут применить одну операцию одновременно ко множеству объектов. Поэтому, модель волоса можно сделать как цепь точек, каждая из которых взаимодействует с двумя соседями. Взаимодействие между точками — по принципу пружины: k * (S0-S)^n, где S0 — дистанция равновесия, S — текущая дистанция. В реальности волос не похож на пружину, он воспринимается нерастягиваемым. Значит, пружину в модели надо сделать достаточно жёсткой. Повышать жёсткость пружины лучше повышая n, потому что степень увеличивает кривизну кривой в окрестности равновесия, что уменьшает люфт и снижает эффект «резиновости» волоса. Я взял n = 2, а о величине коэффициента k поговорим ниже.
Кроме силы упругости между точками будет реализована диффузия относительных скоростей или одномерная вязкость. Обмен тангенциальносй составляющей скорости моделирует динамическое сопротивление растяжению, а обмен нормальной характеристикой скорости — динамичекое сопротивление сгибу. Всё вместе это ускорит передачу возмущений вдоль волоса, что улучшит динамику, сделает волос визуально более связным и менее пружинистым.
Кроме того, будет ещё и статическое стремление к распрямлению. Каждая точка будет стремиться скомпенсировать сгиб волоса. Если в точке будет сгиб, на точку будет действовать сила пропорциональная величине сгиба и направленная в направлении уменьшения величины сгиба. Две соседние с местом сгиба точки будут испытывать вдвое меньшую силу в противоположном направлении.
Этих взаимодействий достаточно, чтобы смоелировать физику волоса, но ею мы не ограничимся. Нужно добавить взаимодействие волоса с твёрдыми объектами. В этом есть практический смысл. Дело не только в том, что физические модели как правило включают взаимодействие между собой разных параллельно моделируемых сущностей, например, жидкости и твёрдых тел. Но и в том, что в практических задачах, например, в играх, GPU-симуляция должна в реальном времени взаимодействовать с объектами, вычисляемыми на стороне CPU. Так что я не мог не уделить внимание такому взаимодействию. У нас волосы будут взаимодействовать с твёрдыми телами, информация о которых будет передаваться в видеопамять в каждом такте.
Для простоты мы будем работать только с круглыми объектами. На стороне CPU у нас будет несколько circle colliders из стандартной 2д-физики юнити. И правило взаимодействия будет такое: если точка волоса окажется внутри твёрдого тела, она переносится наружу, а из скорости такой точки вычитается фракция, направленная в сторону тела, и эта же фракция передаётся телу. Абсолютную скорость тела мы учитывать не будем, для простоты.
Алгоритм, код и подготовка шейдера
Эти три пункта слишком сильно связаны, чтобы обсуждать их по отдельности.
Для описания точки, из множества которых сделаны волосы, мы используем такую структуру:
struct hairNode{
float x; // позиция в двумерном пространстве
float y; //
float vx; // скорость
float vy; //
int dvx; // сумма сил - изменение скорости на данном шаге
int dvy; //
int dummy1; // эти два параметра добавлены для кратности 128 битам
int dummy2; //
}
Эта структура объявлена дважды: на стороне CPU и на стороне GPU. Для удобства. На стороне CPU мы записываем начальные данные, копируем их в GPU-буффер, и дальше они там обрабатываются. Но можно было объясить эту структуру только на стороне GPU, если нам не требуется передавать начальных данных.
Насчёт параметров dummy1 и dummy2. В статье, написанной инженером из nvidia я прочитал, что данные буфферов видеопамяти лучше держать кратными 128 битам. Поскольку это уменьшает количество операций, необходимых для вычисления смещения.
Значения остальных параметров, полагаю, понятны. Хотя, внимательный читатель может спросить: почему скорость имеет тип float, а изменение скорости — int? Короткий ответ: потому что изменение скорости модифицируется одновременно параллельными потоками, и чтобы избежать ошибок в вычислениях, нужно использовать защищённую запись. А функция защищённой записи работает только с целочисленными переменными. Подробней я расскажу об этом ниже.
Точек, которыми мы моделируем волосы, у нас много. Данные обо всех точках хранятся в видеопамяти и доступны через интерфейс буффера:
RWStructuredBuffer<hairNode> hairNodesBuffer;
В коде шейдера мы определяем только его имя и тип данных, а его размер задаётся снаружи, со стороны выполняемого на процессоре кода.
Как структурирован код компьют шейдера, что это вообще такое? Код состоит кернелов. Это то же самое, что методы, но выполняется каждый кернел параллельно на множестве ядер. Поэтому, для каждого указывается количество потоков в виде трёхмерной структуры.
Вот так выглядит пустой кернел, в котором нет никакого кода, только необходимая внешняя информацияё:
#pragma kernel kernelName
[numthreads(8,4,1)]
void kernelName (uint3 id : SV_DispatchThreadID){
// здесь должен быть код
}
У кернела есть входной параметр id, который хранит трёхмерный индекс потока. Это очень удобно, каждый поток знает свой индекс, а значит, может работать со своей отдельной единицей данных.
Со стороны процессорного кода кернел вызывается так:
shaderInstance.Dispatch(kernelIndex, 2, 2, 1);
Вот эти три цифры «2, 2, 1» связаны со строкой, предваряющей соответствующий кернел:
[numthreads(8,4,1)]
Эти две тройки цифр определяют количество потоков, то есть количество параллельных экземпляров кернела. Нужно их просто перемножить: 8 * 4 * 1 * 2 * 2 * 1 = 128 потоков.
Адресация потоков будет своя по каждой оси. В данном случае по оси x будет 8 * 2 = 16 единиц. По оси у 4 * 2 = 8 единиц. То есть, если кернел вызывается так:
ComputeShader.Dispatch(kernelIndex, X, Y, Z);
А на стороне шейдера число потоков задано так:
[numthreads(x,y,z)]
То у нас будет (X * x) * (Y * y) * (Z * z) потоков
Для примера предположим, что нам надо обработать каждый пиксел текстуры размером 256 x 256, и мы хотим чтобы каждым пикселом занимался отдельный поток. Значит, можем определить количество потоков так:
Dispatch(kernelIndex, 16, 16, 1);
и на стороне шейдера:
[numthreads(16,16,1)]
Внутри кернела параметр id.x примет величины в диапазоне [0, 255], то же самое — параметр id.y
А значит, вот такая строка:
texture[id.xy]=float4(1, 1, 1, 1);
окрасит в белый цвет каждый из 65536 пикселов текстуры
id.xy — это то же самое, что uint2(id.x, id.y)
Если эта часть, связанная с количеством потоков, кому-то непонятна, советую пойти в упомянутое мной более лёгкой руководство, и посмотреть, как всё это на практике используется для рисования фрактала Мандельброта посредством простейшего шейдера.
Текст шейдера в рассматриваемой нами модели содержит несколько кернелов, которые по очереди запускаются на стороне CPU в методе Update(). Я потом рассмотрю текст каждого кернела, а сначала кратко объясню, что каждый из них делает.
calc — вычисляются тангенциальная и нормальная силы взаимодействия между частицами: сила натяжения «пружин» толкает частицы вдоль линии между ними, а сила «жёсткости на сгиб» толкает частицы перпендикулярно линии между соседними частицами; рассчитанные величины сил сохраняются для каждой частицы
velShare — частицы обмениваются относительными скоростями. Тангенциальной и полной состоавляющими — по отдельности. Зачем выделять тангенциальную, если потом всё равно идёт обмен полной скоростью? Обмен тангенциальной скоростью должен быть гораздо интенсивней, чем нормальной, при ней должен быть коэффициент повыше, так что её надо было выделить. Тогда зачем во втором случае я не использую чистую нормальную составляющую, а использую полную скорость? Чтобы сэкономить на вычислениях. Изменения в скорости записываются в виде сил, Как и в предыдущем кернеле.
interactionWithColliders — каждая точка взаимодействует с коллайдерами, информация о которых содержится в обновляемом в каждом цикле буффере
calcApply — рассчитанные в предыдущих кернелах силы прибавляются к скорости, а скорости изменяют координаты точек
visInternodeLines — между точками рисуются линии в специальном буффере длиной 1024 x 1024 (пока ещё не на текстуре)
pixelsToTexture — а тут величины из упомянутого уже переводятся в цвета пикселей на текстуре размером [1024, 1024]
clearPixels — все величины промежуточного буффера (в котором мы рисовали линии) обнуляются
clearTexture — очищается текстура
oneThreadAction — этот кернел выполняется в одном единственном потоке, он нужен чтобы плавно передвигать всю систему волос туда, куда мы мышкой её перетащили. Плавность нужна чтобы система от резкого перемещения не ушла вразнос (как вы помните, в нашей модели силы между частицами пропорциональны квадрату расстояния между ними).
На стороне CPU-кода
Теперь я покажу, как эти кернелы запускаются со стороны CPU-кода. Но сначала — о том, как подготовить шейдер к запуску.
Объявляем переменную:
ComputeShader _shader;
Инициализируем её, указывая файл с текстом шейдера:
_shader = Resources.Load<ComputeShader>("shader");
Задаём константы, которые нам пригодятся на стороне GPU
// переменные nodesPerHair и nHairs уже инициализированы
_shader.SetInt("nNodsPerHair", nodesPerHair);
_shader.SetInt("nHairs", nHairs);
Объявляем переменные для массива, который будет хранить данные моделируемых точек, и для буффера, черз интерфейс которого мы сможем читать и писать данные в видеопамять
hairNode[] hairNodesArray;
ComputeBuffer hairNodesBuffer;
Инициализируем буффер и записываем данные массива в видеопамять
// hairNodesArray уже инициализирован
hairNodesBuffer = new ComputeBuffer(hairNodesArray.Length, 4 * 8);
hairNodesBuffer.SetData(hairNodesArray);
Для каждого кернела устанавливаем используемые буфферы, чтобы кернел мог читать и писать данные в этот буффер
kiCalc = _shader.FindKernel("calc");
_shader.SetBuffer(kiCalc, "hairNodesBuffer", hairNodesBuffer);
Когда все необходимые буфферы созданы и установлены для всех кернелов шейдера, можно запускать кернелы.
Все кернелы запускаются из Update(). Из FixedUpdate() их запускать не следует (будет сильно лагать), потому что графический конвейр синхронизирован с Update().
Кернелы запускаются вот в такой последовательности (привожу целиком код вызываемого в Update() метода «doShaderStuff»):
void doShaderStuff(){
int i, nHairThreadGroups, nNodesThreadGroups;
nHairThreadGroups = (nHairs - 1) / 16 + 1;
nNodesThreadGroups = (nodesPerHair - 1) / 8 + 1;
_shader.SetFloats("pivotDestination", pivotPosition);
circleCollidersBuffer.SetData(circleCollidersArray);
i = 0;
while (i < 40) {
_shader.Dispatch(kiVelShare, nHairThreadGroups, nNodesThreadGroups, 1);
_shader.Dispatch(kiCalc, nHairThreadGroups, nNodesThreadGroups, 1);
_shader.Dispatch(kiInteractionWithColliders, nHairThreadGroups, nNodesThreadGroups, 1);
_shader.Dispatch(kiCalcApply, nHairThreadGroups, nNodesThreadGroups, 1);
_shader.Dispatch(kiOneThreadAction, 1, 1, 1);
i++;
}
circleCollidersBuffer.GetData(circleCollidersArray);
_shader.Dispatch(kiVisInternodeLines, nHairThreadGroups, nNodesThreadGroups, 1);
_shader.Dispatch(kiClearTexture, 32, 32, 1);
_shader.Dispatch(kiPixelsToTexture, 32, 32, 1);
_shader.Dispatch(kiClearPixels, 32, 32, 1);
}
Сразу бросается в глаза, что несколько кернелов запускаются 40 раз за апдейт. Зачем? Чтобы при малом временном шаге симуляция работала быстро в реальном времени. А почему временной шаг должен быть мал? Для уменьшения ошибки дискретизации, то есть для стабильности системы. А как и почему возникает нестабильность? Если шаг большой, и на точку действует большая сила, то за один шаг точка улетает далеко, возвратная сила становится ещё больше, и на следующем шагу точка улетает в другую сторону ещё дальше. Результат: система уходит вразнос, все точки летают туда-сюда с нарастающей амплитудой. А при малом шаге все кривые сил и скоростей очень плавные, потому что погрешности сильно уменьшаются с уменьшением временного шага.
Так что вместо одного большого шага система делает 40 маленьких шагов в каждом цикле, и благодаря этому демонстрирует высокую точность вычслений. Благодаря высокой точности можно работать с большими силами взаимодействия без потери стабильности. А большие силы означают, что у нас не вялые пружинистые макаронины в модели болтаются, норовя взорваться от резкого движения, а бодро вертятся прочные волосики.
Данные о точках, которыми мы моделируем волосы, хранятся в видеопамяти в виде одномерного массива, к которому мы обращаемся через интерфейс буффера.
Для удобства работы с одномерным буффером мы индексируем потоки следующим образом: (ось x: количество волос * ось у: количество точек в волосе). То есть, у нас будет двумерный массив потоков, каждый из которых будет знать свою точку по индексу потока.
Как вы помните, количество потоков, в которых выполняется кернел, определяется произведением параметров метода Dispatch() и параметров директивы [numthreads()] в шейдерном коде.
В нашем случае все кернелы, работающие с точками волос, предварены директивой [numthreads(16,8,1)]. Поэтому, параметры метода Dispatch() должны быть таковы, чтобы произведение давало число потоков не меньшее, чем нам требуется для обработки всего массива точек. В коде мы рассчитываем параметры х и у метода Dispatch():
nHairThreadGroups = (nHairs - 1) / 16 + 1;
nNodesThreadGroups = (nodesPerHair - 1) / 8 + 1;
Взаимоотношение параметров [numthreads()] и Dispatch() проистекает из архитектуры графических вычислителей. Первое — это количество потоков в группе. Второе — это количество групп потоков. Их соотношение влияет на скорость работы. Если нам требуется 1024 потока по оси x, лучше сделать 32 группы по 32 потока, чем 1 группу по 1024 потока. Почему? Для ответа на этот вопрос нужно много рассказать об архитектуре GPU, оставим эту слишком глубокую тему незатронутой.
Подробности GPU-кода
Итак, 40 раз за апдейт мы запускаем по очереди кернелы, вычисляющие изменение скорости точек и изменяющие их скорости и координаты. Давайте рассмотрим код каждого кернела. Там всё довольно просто, нужно только усвоить пару специфических особенностей.
Кернел «calc» вычисляет изменение скорости точек. Точки в буффере «hairNodesBuffer» расположены по очереди, сначала первая точка первого волоса, потом вторая, и так до последней. Потом сразу первая точка второго волоса, и так далее по всем волосам, до конца буффера. Мы помним, что у кернела есть параметр id, и в нащем случае id.x указывает на номер волоса, а id.y — на номер точки. И вот, как мы получаем доступ к данным точек:
int nodeIndex, nodeIndex2;
hairNode node, node2;
nodeIndex = id.x * nNodesPerHair + id.y;
nodeIndex2 = nodeIndex + 1;
node = hairNodesBuffer[nodeIndex];
node2 = hairNodesBuffer[nodeIndex2];
Здесь величина nNodesPerHair — это константа, которую мы задали на стороне CPU при инициализации шейдера. Данные из буффера скопированы в локальные переменные node и node2 потому, что обращение к данным буффера может требовать больше циклов ядра, чем обращение к локальной переменной. Сам алгоритм таков: для каждой точки, если она не последняя в волосе, мы рассчитываем силу, действующую между ней и следующей точкой. На основе этой силы мы записываем изменение скорости в каждую из точек.
Вот важная особенность параллельного вычисления: каждый поток модифицирует две точки, текущую и следующую, а значит, каждую точку модифицируют два параллельных потока. Незащищённая запись в общие для параллельных потоков пременные чреват потерей данных. Если пользоваться обычным инкрементом:
variable += value;
то запись может произойти одновременно, вот таким образом: первый поток скопирует исходное значение, прибавит к нему единицу, но прежде чем он запишет величину обратно в ячейку памяти, второй поток возьмёт исходное значение. Затем первый поток запишет увеличенное на единицу значение обратно. После чего второй поток добавит свою единицу и запишет увеличенное значение обратно. Результат: хотя два потока добавили по единице, переменная увеличилась только на одну единицу. Чтобы избежать этой ситуации, используют защищённую записаь. В HLSL есть несколько функций для защищённой модификации обобщёных переменных. Они гарантируют, что данные не пропадут и учтётся вклад каждого потока.
Небольшая проблема состоит в том, что эти функции работают только с целочисленными переменными. И именно поэтому в структуре, описывающей состояние точки мы используем параметры dvx и dvy типа int. Чтобы была возможность писать в них с помощью защищённых функций и не терять данные. Но для того, чтобы не терять точности на округлении, мы заранее определили множители. Один переводит float в int, другой — обратно. Так мы используем весь дианазон int-величины, и не теряем в точности (теряем, конечно, но пренебрежимо мало).
Защищённая запись выглядит так:
InterlockedAdd(hairNodesBuffer[nodeIndex].dvx, (int)(F_TO_I * (dv.x + 2 * dvFlex.x)));
InterlockedAdd(hairNodesBuffer[nodeIndex].dvy, (int)(F_TO_I * (dv.y + 2 * dvFlex.y)));
InterlockedAdd(hairNodesBuffer[nodeIndex2].dvx, (int)(F_TO_I * (-dv.x - dvFlex.x)));
InterlockedAdd(hairNodesBuffer[nodeIndex2].dvy, (int)(F_TO_I * (-dv.y - dvFlex.y)));
Здесь F_TO_I — упомянутый коэффициент для проекции float на int, dv — вектор силы влияния второй частицы на первую через пружинную связь. А dvFlex — распрямляющая сила. "(int)" нужно добавлять потому, что InterlockedAdd() перегружен для типов int и uint, и float по умолчанию интерпретируется как uint.
Кернел «velShare» похож на предыдущий, в нём тоже модифицируются параметры dvx и dvy двух соседствующих точек, но вместо расчёта сил, рассчитывается диффузия относительной скорости.
В кернеле «interactionWithColliders» точки не взаимодействуют друг с другом, тут каждая точка пробегает по всем коллайдерам буффера твёрдых тел (который мы в каждом апдейте обновляем). То есть каждый поток пишет только в одну частицу, нет опасности одновременной записи, и поэтому вместо InterlockedAdd() мы можем напрямую изменять скорость частицы. Но при этом наша модель подразумевает, что точки передают импульс коллайдеру. Значит, параллельные потоки могут одновременно изменять величину импульса одного и того же коллайдера, а значит, используем защищённый вариант записи.
Только тут нужно понимать: когда мы проецируем float на int, у нас конкурируют целая и дробная части. Точность конкурирует с диапазоном величины. Для случая взаимодействия точек мы выбрали коэффициент, допускающий достаточный для нас разброс величины, а остальное пустили на точность. Но для передачи импульса коллайдеру этот коэффициент не годится, потому что одновременно сотни точек могут добавить свой импульс в одном направлении, и поэтому надо пожертвовать точностью в пользу способности вместить большое число. Так что при защищённой записи мы не используем коэффициент F_TO_I, а используем коэффициент поменьше.
После того, как все взаимодействия точек рассчитаны, мы в кернеле «calcApply» прибавляем импульс к скорости, а скорости к координатам. Кроме того, в этом кернеле каждая корневая (первая по счёту) точка волоса фиксируется в определённом месте относительно текущего положения всей системы волос. Ещё в этом кернеле к вертикальной составляющей скорости прибавляется вклад гравитации. Плюс, реализуется «торможение» о воздух, то есть абсолютная величина скорости каждой точки умножается на коэффициент чуть меньше единицы.
Обратим внимание, что в кернеле «calcApply» скорость влияет на координаты через посредство коэффициента «dPosRate». Он определяет величину шага моделирования. Этот коэффициент задан на стороне CPU и хранится в переменной, которую я так и назвал «simulationSpeed». Чем больше этот параметр, тем быстрей систему будет эволюционировать во времени. Но тем ниже будет точность расчёта. Точность расчёта, повторюсь, ограничивает величину сил, так как при больших силах и низкой точности величина ошибки так велика, что именно она определяет поведение модели. Мы взяли скорость моделировани довольно низкую, это даёт нам большую точность, поэтому мы можем себе позволить большие силы, а значит более реалистичное поведение модели.
За величину сил отвечает коэффициент, связывающий воздействие импульса на скорость — «dVelRate». Этот коэффициент у нас большой, он задан на стороне CPU и называется «strengthOfForces».
Повторюсь, что во всех упомянутых кернелах количество потоков равно количеству точек, один поток отвечает за обработку одной точки. И это хорошая практика. Мы ничего не платим за количество потоков, их может быть сколько угодно (в shader model 5.0 — не больше 1024 по осям x и y и не больше 64 по оси z). В традиции параллельных вычислений лучше избегать использования циклов для выполнения в одном потоке одной операции по отношению к нескольким единицам данных, лучше сделать столько потоков, сколько требуется для реализации принципа «одна единица данных — один поток».
Вернёмся в метод doShaderStuff() на стороне CPU-кода. После выполнения цикла из 40 шагов вычисления модели волос, мы считываем данные коллайдера:
circleCollidersBuffer.GetData(circleCollidersArray);
Можно вспомнить, что на стороне GPU в буффер с данными коллайдера записываются импульсы со стороны волос, и их мы используем на стороне CPU для приложения силы к rigidbody. Заметим, что сила к rigidbody прилагается в методе FixedUpdate(), поскольку он синхронизирован с физикой. При этом, данные об импульсе обновляются в Update(). А значит, под воздействием разных факторов, за один Update() может произойти несколько FixedUpdate() и наоборот. То есть, во влиянии волос на коллайдер нет абсолютной точности, часть данных может быть перезаписана прежде, чем оказать влияние, а другие данные могут оказать влияние дважды. Можно принять меры, чтобы этого не происходило, но в рассматриваемой программе этих мер не принято.
Тут стоит ещё отметить, что метод GetData() приостанавливает работу графического конвейра, что вызывает ощутимое замедление работы. Асинхронной версии этого метода в юнити, к сожалению, пока не реализовано, хотя, по слухам, в 2018 году она появится. А пока нужно понимать, что если в вашей задаче необходимо копировать данные из GPU в CPU, программа будет работать на 20-30% медленнее. При этом, метод SetData() такого эффекта не имеет, работает быстро.
Визуализация
Оставшиеся кернелы, запускаемые в методе doShaderStuff(), связаны только с визуализацией системы волос.
Рассмотрим всё, что касается визуализации.
На стороне CPU мы объявляем переменную RenderTexture, не забываем установить enableRandomWrite = true, и используем её в качестве mainTexture в материале UI-компонента Image.
И затем для каждого кернела, который должен писать в эту текстуру, мы вызываем метод SetTexture(), чтобы связать наш объект RenderTexture со переменной на стороне шейдера:
RenderTexture renderTexture;
renderTexture = new RenderTexture(1024, 1024, 32);
renderTexture.enableRandomWrite = true;
renderTexture.Create();
GameObject.Find("canvas/image").GetComponent<UnityEngine.UI.Image>().material.mainTexture = renderTexture;
_shader.SetTexture(kiPixelsToTexture, "renderTexture", renderTexture);
На стороне шейдера у нас объявлена переменная типа RWTexture2D, через посредство которой мы задаём цвета пикселей текстуры:
RWTexture2D<float4> renderTexture;
Теперь рассмотрим кернел очистки текстуры, который вызывается перед записью в неё цветных пикселей:
#pragma kernel clearTexture
[numthreads(32,32,1)]
void clearTexture (uint3 id : SV_DispatchThreadID){
renderTexture[id.xy] = float4(0, 0, 0, 0);
}
Запускается этот кернел так:
_shader.Dispatch(kiClearTexture, 32, 32, 1);
Мы видим, что у нас 1024 x 1024 потоков, по потоку на пиксель. Что удобно: просто используем параметр id.xy для адресации пиксела.
Как именно рисуются волосы? Я решил сделать волосы полупрозрачными, чтобы при их пересечении цвет был более насыщенным, из чего вытекает необходимость использовать защищённую запись, так как две линии могут рисоваться одновременно на одном пикселе, покольку, как и в уже рассмотренных кернелах, у нас все точки будут выполняться одновременно в количестве потоков, равном количеству точек. Само рисование тривиально: от каждой точки проводим линию к следующей точке. Существуют специальные алгоритмы для выбора множества квадратных пикселей, заметаемых линией, но я решил пойти по простому пути: линия рисуется путём продвижения маленькими шагами вдоль линии между двух точек.
Так как используется инкремент, я пишу данные о цвете в буффер, а не в текстуру. Текстура почему-то не читается, хотя вроде бы должна.
После того, как кернел «visInternodeLines» прочертил все линии, мы копируем пиксели из буффера в текстуру. Я не использовал никаких цветов, рисуются только градации серого. Если бы мне понадобился цвет, то вместо буффера RWStructuredBuffer я использовал бы RWStructuredBuffer или можно было бы запиывать 4 параметра цвета в один uint.
Кстати, этот метод с RenderTexture не работает на маках, и получить ответ на вопрос «почему» на форуме мне не удалось.
Существуют и другие методы визуализации данных из compute shader, но я, признаться, их пока не изучал.
После того, как кернел «pixelsToTexture» модифицировал текстуру, у нас на экране появляется изображения развевающихся волос.
Я рассказал обо всех участках кода, касающихся вычислений на GPU. Информации в руководстве довольно много, и может быть сложно её разом усвоить. Если вы планируете экспериментировать в этой области, я советую с нуля написать простенькую программу, чтобы через практику закрепить знания. Считайте это домашним заданием. Выполнить его будет просто и полезно.
Напишите шейдер с одним кернелом, возводящим в квадрат все числа из большого массива. На стороне CPU подготовьте массив, запишите его в буффер шейдера, запустите кернел, потом получите информацию из видеопамяти и проверьте, возведены ли числа в квадрат.
Комментарии (8)
rabbitator
09.01.2018 11:34Пробовал работать с вычислительными шейдерами в юнити, но почему-то не получилось отправить на видеокарту буфер с массивом структур, в каждой из которых был ещё один массив данных. Видимо для таких задач нужно использовать CUDA.
ThisIsZolden Автор
09.01.2018 11:40CUDA — это просто другое API. Методы передачи данных в видеопамять похожи. Если у вас были массивы в структурах, значит там был не сам массив, а четырёхбайтный адрес. То есть, следовало записать данные массивов в буффер тоже.
Mabu
09.01.2018 11:34На анимированной картинке волосы, которые совершенно не спутываются, например, от статического электричества.
Это выглядит так, будто это не волосы, а шторы из нитей.ThisIsZolden Автор
09.01.2018 11:35Волосы в модели друг с другом не взаимодействуют. Это для простоты, чтобы не объяснять слишком много.
greabock
Немного про макаронины и большие силы. Я от физики вообще и от ее компьютерного моделирования в частности весьма далек. Но мне вот сейчас в голову пара мыслей пришла.
1) Возможно количество циклов пересчета коллизий должно коррелировать со скоростью движения и допустим близостью и скоростью движения ближайших объектов?
2) Может стоит использовать какие-то эвристики для предсказания коллизий?
Идеи, наверняка, не очень оригинальные, так как лежат на поверхности. Но всё же…
А что вы об этом думаете?
ThisIsZolden Автор
Для параллельного вычисления больше подходят простые операции, равно применяемые к каждому из моделируемых объектов.
Если добавить эвристику на стороне GPU, она будет потреблять вычислительные такты, и её добавление должно будет окупаться. Но окупаться оно не будет, потому что если каким-то образом для медленных частиц снизить объём вычислений, то всегда найдётся одна быстрая частица, требующая всего объёма вычислений. И получится, что 1023 ядра ждут одно, которое обрабатывает эту одну частицу, и время выполенения операции над одной частицей не будет меньше, чем над всеми.