turing shaders что такое
Turing shaders что такое
Вход на сайт
Архитектура Turing и новые технологии NVIDIA GeForce RTX
Жизненный цикл графических ускорителей Pascal оказался довольно долгим. И у старших GeForce GTX за два года так и не появилось достойных конкурентов с стороны AMD. NVIDIA не спешила обновлять модельный ряд даже после запуска новой архитектуры Volta для специализированных ускорителей вычислений. Единственным игровым продуктом в этом семействе стала видеокарта TITAN V. Полноценный старт нового семейства GeForce на совершенно новой архитектуре Turing состоялся лишь этой осенью. На данный момент представлено три модели — GeForce RTX 2080 Ti, GeForce RTX 2080 и GeForce RTX 2070, вскоре будут представлены и другие решения. Все эти видеокарты мы рассмотрим в отдельных обзорах с практическими тестами в играх. В данном материале поговорим о технических и архитектурных нюансах.
GPU Turing и характеристики GeForce RTX
Архитектура Turing принесла серьезные изменения и ряд инноваций, расширяющих возможности компьютерной графики. Само название видеокарт NVIDIA впервые за долгие годы было изменено с GeForce GTX на GeForce RTX, чтобы подчеркнуть поддержку технологии трассировки лучей NVIDIA RTX в новом поколении. Кроме трассировки, которая у всех на слуху, новые GPU получили важные улучшения и для станлдартного рендеринга. Расширены возможности применения технологий глубокого обучения. А референсные видеокарты Founders Esition вышли на новый инженерный уровень, получив более качественное охлаждение.
Каждая из трех представленных видеокарт использует свой GPU. Это нестандартный подход. Обычно в старшем сегменте мы видим по две-три модели на базе одного GPU, где меняется лишь конфигурация активных вычислительных блоков. И особняком стоит флагманская видеокарта с самым сложным чипом. В этот раз им является процессор TU102. Он имеет знакомую кластерную структуру. Все блоки организованы в шесть крупных кластеров GTC. В рамках кластера вычислительные боки сгруппированы в шесть текстурно-процессорных кластеров TPC, каждый по два мультипроцессора SM. Непосредственно SM насчитывает 64 минимальных вычислительных единицы — ядра CUDA. При 72 SM старший процессор насчитывает 4608 ядер CUDA и 288 текстурных блоков ROP.
Однако топовая видеокарта GeForce RTX 2080 Ti оснащается урезанным TU102, где отключено четыре SM. Поэтому карта оперирует 4352 ядрами CUDA при 272 текстурных блоках. Также в ее активе 544 тензорных ядра и 68 ядра RT для трассировки лучей. Аналогичная ситуация была с GeForce GTX 1080 Ti, где GPU GP102 тоже работал в неполной конфигурации. Полноценная версия TU102 стала основной для профессионального ускорителя Quadro RTX 6000 и для TITAN RTX. Если сравнивать GeForce RTX 2080 Ti с GeForce GTX 1080 Ti, то мы имеем 4352 вместо 3584 потоковых процессоров, то есть прирост вычислительной мощности в любом случае будет внушительный. Процессор TU102 работает с памятью через 12 контроллеров разрядностью по 32 бита, т.е. общая разрядность шины 384 бит. GeForce RTX 2080 Ti работает на 352-битной шине с памятью GDDR6 объемом 11 ГБ. В сравнении со старым поколением заметно вырост объем кэша L2. У GeForce RTX 2080 Ti кэш L2 5632 КБ, а полная версия GPU оперирует 6 МБ. У GeForce GTX 1080 Ti кэш меньше 3 МБ.
Процессор изготовляется по новому 12-нм техпроцессу. Кристалл TU102 довольно крупный. Его площадь 754 мм², что на 60% крупнее кристалла GP102. При этом новый чип насчитывает 18,6 млрд. транзисторов против 12 млрд. у старого топового процессора.
Следующим в иерархии является процессор TU104, который задействован в GeForce RTX 2080. Он сохранил шесть кластеров GPC, но в конфигурации по четыре TPC на кластер. Обычно кластерная структура для старших GPU неизменна, но в новом семействе возможны разные комбинации. При 48 SM процессор TU104 насчитывает 3072 ядер CUDA и 192 текстурных блока. Однако и тут не обошлось без упрощений, для GeForce RTX 2080 отключено два SM, поэтому активно 2944 ядра CUDA и 184 ROP, плюс 368 тензорных ядра и 46 RT-core. Процессор сообщается с памятью GDDR6 по 256-битной шине. Объем памяти 8 ГБ, что на уровне GeForce GTX 1080. Кэш L2 4 МБ, что выше чем у GP102 (GeForce GTX 1080 Ti).
По характеристикам GeForce RTX 2080 напоминает промежуточный вариант между GeForce GTX 1080 и GeForce GTX 1080 Ti, но по уровню быстродействия соперничает со старшей видеокартой Pascal. Процессор TU104 насчитывает 13,2 млрд. транзисторов при площади кристалла 545 мм². Это более сложный и более крупный чип на фоне GP102. И в такой ситуации NVIDIA все равно смогла достичь более низкого энергопотребления GeForce RTX 2080 в сравнении с GeForce GTX 1080 Ti (225 Вт против 250 Вт).
GeForce RTX 2070 использует процессор TU106. Этот GPU напоминает половинку от старшего чипа — три кластера и 36 SM. Общий потенциал процессора таков: 2304 ядра CUDA, 144 ROP, 288 тензорных ядер и 36 ядер RT. GeForce RTX 2070 обходится без всяких упрощений и задействует все вычислительные блоки. Шина памяти 256 бит, объем 8 ГБ, тип памяти GDDR6. Объем кэш-памяти L2 равен 4 МБ, как и у старшего TU104.
TU106 насчитывает 10,8 млрд транзисторов, площадь кристалла 445 мм². Заявленный TDP 175-185 Вт. Согласно цифровому индексу GeForce RTX 2070 можно позиционировать как преемника для GeForce GTX 1070. Но в каждом поколении всегда есть определенный рывок для решений одного уровня, поэтому GeForce RTX 2070 на самом деле является конкурентом для GeForce GTX 1080.
Отдельно поговорим о частотах. В характеристиках видеокарт NVIDIA указывается базовое значение частоты GPU, что отвечает минимально возможной частоте при максимальной нагрузке, плюс среднее значение Boost Clock. Если сравнивать GeForce RTX с предшественниками по базовому значению, то есть небольшое снижение, но реальные частоты Boost примерно на одном уровне. И у новых видеокарт заявлено по две частотные конфигурации. NVIDIA определяет минимальные частоты для видеокарт производства партнеров и более высокие частоты для своих продуктов линейки Founders Edition. Самые простые GeForce RTX 2080 Ti работают при базовой частоте 1350 МГц и Boost Clock 1545 МГц, а GeForce RTX 2080 Ti Founders Edition при частотах 1350/1635 МГц. Для GeForce RTX 2080 это сочетание частот 1515/1710 МГц и 1515/1800 МГц, для GeForce RTX 2070 это 1410/1620 МГц и 1410/1710 МГц. Более высокие частоты Founders Edition являются ключевым фактором, которым можно оправдать повышенный ценник таких видеокарт. Что касается памяти, то у всех GeForce RTX используются модули GDDR6, которые работают с пропускной способностью, эквивалентной значению 14000 МГц.
Разница в частотах обуславливает и различные данные в заявленном TDP. Для GeForce RTX 2080 Ti это 250 Вт или 260 Вт для Founders Edition, у GeForce RTX 2080 это 215 Вт или 225 Вт. Младшая видеокарта флагманской тройки работает при 175 Вт на обычных частотах и при 185 Вт с повышенными частотами.
Поговорим о шейдерах, их проектировании и Unity
Воруй как художник
Для разминки я бы в целом предложил довольно простое упражнение. Подобное есть у художников, только тут оно немного сложнее. Делать базовые материалы. Фарфор, акрил, стекло, воск, грунт, кафель, пластик, металлы и так далее. Материалов с разными физическими и оптическими свойствами существует очень много. Но не в стиле подкрасил и натянул на Standard шейдер текстурки с https://textures.com/ и готово. А размышляя о том, как это сделать в рамках ограничений.
Допустим поставить себе ограничение, что шейдер обязательно должен быть однопроходным. Можно сказать, что в данном случае ряд эффектов невозможным, но это не совсем правда. Из перечисленного выше ряд эффектов становится невозможным если делать “честно”, по математической модели материала или физического явления. А тут нужно думать “на что это похоже” и “где я могу выиграть в производительности”.
В целом если вы хотите уметь создавать эффекты от и до, то лучше обладать некоторой арт экспертизой, чтобы делать что-то совсем новое и уникальное. Но в целом, на мой взгляд, основная задача разработчика реализовывать безумные требования художников/геймдизайнеров/дизайнеров/бизнеса.
Любому хорошему повару нужны заготовки
Поэтому по любой материал, эффект или явление можно разобрать на набор составляющих его компонент и разработать оптимальное решение, которое похоже на данный компонент в рамках вашей задачи. Просто скажем сингл пасс шейдер не единственное интересное ограничение, может у вас 2.5D игра и в этом случае многие эффекты делаются проще. Или вам не нужно супер реалистичное подповерхностное рассеивание, то тогда вам подойдёт просто похожий на него эффект, который +- ничего не стоит. Например в данной статье был неплохой вариант https://habr.com/ru/post/337370/ Или вы знаете размещение источников света в сцене. В каждом конкретном случае упрощающих задачу ограничений может быть очень много вплоть до знания особенностей геометрии.
Это лишь пара простых примеров. Можно ещё задать параметр ветра в виде вектора и описать его упрощённую логику или полную. Но суть размышлений +- такова во многих случаях. Думаем на что это похоже из того, что можно сделать простыми средствами.
Но что же по поводу конкретных заготовок. Которые нужны любому “повару” отвечающему за разработку эффектов. Их очень много я перечислю лишь часть.
Руководство начинающего программиста графических шейдеров
Умение писать графические шейдеры открывает перед вами всю мощь современных GPU, которые сегодня уже содержат в себе тысячи ядер, способных выполнять ваш код быстро и параллельно. Программирование шейдеров требует несколько иного взгляда на некоторые вещи, но открывающийся потенциал стоит некоторых затрат времени на его изучение.
Практически каждая современная графическая сцена являет собой результат работы некоторого кода, написанного специально для GPU — от реалистичных эффектов освещения в новейших ААА-играх до 2D-эффектов и симуляции жидкости.
Сцена в Minecraft до и после применения нескольких шейдеров.
Цель этой инструкции
Что такое шейдер?
Шейдер — это просто программа, которая запускается на одном из графических ядер и говорит видеокарте, как нужно отрисовать каждый пиксель. Программы называются «шейдерами», поскольку они часто используются для контроля эффектов освещения и затенения («shading»). Но, конечно, нет никаких причин ограничиваться только этими эффектами.
Шейдеры пишутся на специальном языке программирования. Не беспокойтесь, вам не нужно прямо сейчас идти и изучать с нуля новый язык программирования. Мы будем использовать GLSL (OpenGL Shading Language), который имеет С-подобный синтаксис. Существуют и другие языки программирования шейдеров под различные платформы, но, поскольку их конечной целью является всё тот же запуск кода на GPU, они имеют достаточно схожие принципы.
Данная статья будет рассказывать лишь о так называемых пиксельных (или фрагментных) шейдерах. Если вам стало интересно, а какие они бывают ещё — вам следует почитать о графическом конвейере (например, в OpenGL Wiki).
Поехали!
Для наших экспериментов мы воспользуемся ShaderToy. Это позволит вам взять и начать писать шейдерный код здесь и сейчас, не откладывая это дело на потом из-за необходимости устанавливать какие-то определённые инструменты или SDK. Единственное, что вам необходимо — это браузер с поддержкой WebGL. Создавать аккаунт на ShaderToy не обязательно (только, если вы захотите сохранить там свой код).
Заметка: ShaderToy сейчас в стадии беты, так что на момент прочтения вами этой статьи некоторые нюансы его UI могут измениться.
Итак, нажимаем кнопку New в правом углу, что приведёт к созданию нового шейдера:
Маленькая чёрная стрелка под кодом компилирует и запускает шейдер.
Что здесь происходит?
Я сейчас объясню, как работает шейдер, ровно одним предложением. Вы готовы? Вот оно. Единственным предназначением шейдера является вернуть четыре числа: r, g, b и a.
Это всё, что может и должен сделать шейдер.
Функция, которую вы видите выше, запускается для каждого пикселя на экране. И для каждого из них она возвращает четыре вышеуказанных числа, которые и становятся цветом данного пикселя. Так работают Пиксельные Шейдеры (иногда также называемые фрагментными).
Итак, теперь у нас есть достаточно знаний для того, чтобы, например, залить весь экран чистым красным цветом. Значения каждой из компонент rgba (red, green, blue и «alpha» — то есть «прозрачность») может быть в диапазоне от 0 до 1, так что в нашем случае мы просто вернем r,g,b,a = 1,0,0,1. ShaderToy ожидает финальный цвет пикселя в переменной fragColor.
Мои поздравления! Это ваш первый работающий шейдер!
Мини-задание: сможете залить весь экран серым цветом?
vec4 — это просто тип данных, так что мы можем объявить наш цвет как переменную:
Данный пример не слишком захватывающий. У нас есть мощь сотен или тысяч вычислительных ядер, способных работать эффективно и параллельно, а мы из это пушки стреляем по воробьям, заливая весь экран одним цветом.
Давайте хотя бы нарисуем градиент. Для этого, как вы можете догадаться, нам нужно знать позицию текущего пикселя на экране.
Входные параметры шейдера
Каждый пиксельный шейдер имеет в своём распоряжении несколько полезных переменных. В нашем случае наиболее полезной будет fragCoord, которая содержит координаты x и y (а также z, если нужно будет работать в 3D) текущего пикселя. Для начала попробуем закрасить все пиксели в левой половине экрана в черный цвет, а в правой — в красный:
Заметка: для доступа к компонентам переменных типа vec4 вы можете использовать obj.x, obj.y, obj.z, obj.w или obj.r, obj.g, obj.b, obj.a. Это эквивалентные записи. Таким способом мы получаем возможность именовать компоненты vec4 в зависимости от того, чем они являются в каждом конкретном случае.
Вы уже видите проблему с кодом выше? Попробуйте нажать кнопку перехода в полноэкранный режим. Пропорции красной и черной частей экрана изменятся (в зависимости от размера вашего экрана). Для того, чтобы закрасить ровно половину экрана, нам нужно знать его размер. Размер экрана не является встроенной переменной, поскольку это нечто, что программист приложения контролирует сам. В нашем случае это ответственность разработчиков ShaderToy.
Если что-то не является встроенной переменной, вы можете переслать эту информацию от CPU (основного кода вашего приложения) к GPU (вашему шейдеру). ShaderToy делает это за вас. Вы можете просмотреть все доступные шейдеру переменные во вкладке Shader Inputs. В GLSL они называются uniform-переменными.
Давайте исправим наш код таким образом, чтобы он корректно определял середину экрана. Для этого нам понадобится uniform-переменная iResolution:
Теперь даже при увеличении окна предпросмотра (или переходе в полноэкранный режим) мы получим поделенный ровно пополам черно-красный прямоугольник.
От разделения экрана к градиенту
Изменить наш код для получения градиентной заливки достаточно просто. Компоненты цветов могут быть в пределах от 0 до 1, и наши координаты тоже теперь представлены в том же диапазоне.
Мини-задание: попробуете сами сделать вертикальный градиент? Диагональный? Как на счёт перехода между более чем двумя цветами?
Если вы не пропустили вышеуказанное задание с вертикальным градиентом, то уже знаете, что верхний левый угол имеет координаты (0;1), а не (0;0), как можно было бы предположить. Это важно, запомните это.
Рисование изображений
Развлекаться с заливкой цветом, конечно, забавно, но, если мы хотим реализовать какой-нибудь по-настоящему захватывающий эффект, наш шейдер должен быть способен принимать на вход картинку и изменять её. Таким образом мы можем написать шейдер, который может влиять, например, на отрисовку всего кадра в игре (реализовать эффекты движения жидкостей или выполнять цветокоррекцию) или наоборот, выполнять лишь отдельные операции для некоторых объектов сцены (например, реализовать часть системы освещения).
Если бы мы писали шейдеры на какой-нибудь обычной платформе, то должны были бы передать изображение шейдеру как uniform-переменную (таким же образом, как передавалось разрешение экрана). ShaderToy делает это за нас. Есть четыре входных канала внизу:
Кликните на канале iChannel0 и выберите любую текстуру (изображение). Теперь у вас есть картинка, которая будет передана вашему шейдеру. Но есть одна проблема: функции DrawImage() у нас нет. Вы ведь помните — всё, что может сделать шейдер, это вернуть значение rgba для одного пикселя.
Итак, если мы можем лишь вернуть значение цвета, то как же нам отрисовать картинку на экране? Мы должны как-то соотнести пиксель в картинке с пикселем, для которого был вызван шейдер:
Мы можем сделать это с помощью функции texture(textureData,coordinates), которая принимает на вход текстуру и координаты (x, y), а возвращает цвет текстуры в данной точке в виде переменной типа vec4.
Вы можете соотнести пиксели текстуры и экрана как-угодно. Можно, например, растянуть текстуру на четверть экрана или нарисовать лишь её часть. В нашем случае мы всего лишь хотим увидеть оригинальное изображение:
И вот она, наша картинка!
Теперь, когда вы умеете вытягивать данные из текстуры, вы можете манипулировать ими как захотите. Вы можете растянуть или сжать изображение, поиграть с его цветами.
Давайте добавим сюда уже известный нам градиент:
Поздравляю, вы только что написали свой первый пост-процессинг эффект!
Мини-задание: сможете ли вы написать шейдер, который преобразует входную картинку в черно-белое изображение?
Заметьте, хотя мы используем статическую картинку, то, что вы видите на экране рендерится в реальном времени, много раз в секунду. Вы можете убедиться в этом, заменив во входном канале статическую картинку на видео (просто кликните на канале iChannel0 и выберите видео).
Добавляем немного движения
До этого момента все наши эффекты были статические. Мы можем делать намного более интересные вещи, используя входные параметры, предоставляемые нам разработчиками ShaderToy. iGlobalTime это постоянно увеличивающаяся переменная — мы можем использовать её в качестве основы для переодических эффектов. Давайте попробуем поиграть с цветами:
В GLSL есть встроенные функции синуса и косинуса (да и много других полезных). Компоненты цвета не должны быть негативными, так что мы используем функцию abs.
Мини-задание: можете ли вы сделать шейдер, который будет периодически плавно делаеть картинку черно-белой, а потом снова полноцветной?
Отладка шейдеров
При написании обычных программ вы, возможно, использовали возможность отладочного вывода или логирования, но для шейдеров это не очень-то возможно. Вы можете найти какие-то отладочные средства под вашу конкретную платформу, но в общем случае лучше всего представить нужное вам значение в виде некоторой графической информации, которую вы можете увидеть в выводе невооруженным взглядом.
Заключение
Мы рассмотрели лишь базовые средства разработки шейдеров, но вы уже можете экспериментировать с ними и пробовать делать что-то своё. Просмотрите доступные на ShaderToy эффекты и попробуйте понять (или самостоятельно воспроизвести) какие-то из них.
Одна из (многих) вещей, которые я не упомянул в данной статье, это вершинные шейдеры (Vertex Shaders). Они пишутся на том же языке, но запускаются не для пикселей, а для вершин, возвращая, соответственно, новую позицию вершины и её цвет. Вершинные шейдеры занимаются, например, отображением 3D-сцены на экран.
Последнее мини-задание: сможете ли вы написать шейдер, который заменит зелёный фон (есть в некоторых видео на ShaderToy) на другую картинку или видео?
Вот и всё, что я хотел рассказать в данной статье. В следующих я попробую рассказать о системах освещения, симуляции жидкостей и разработке шейдеров под конкретные платформы.
Физическое моделирование на GPU с использованием compute shader в среде Unity3D
Вот проект для 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-кода
Теперь я покажу, как эти кернелы запускаются со стороны 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, оставим эту слишком глубокую тему незатронутой.
Подробности 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 подготовьте массив, запишите его в буффер шейдера, запустите кернел, потом получите информацию из видеопамяти и проверьте, возведены ли числа в квадрат.