Прошло три месяца (см. «Мир ПК», №4/07), и снова здравствуйте! Эта статья для тех, кто уже успел заблудиться в лабиринте трехмерной визуализации, для тех, кто собрал в нем первые плоды, а также для тех, кто хочет в корне изменить свое существование в этом запутанном мире. Вам уже поднадоела жесткая схема OpenGL? Хотите выбраться из конвейера, штампующего грубые полигональные модели, и ощутить настоящую свободу творчества? Тогда этот текст для вас.

А как же трассировка лучей? — спросят самые внимательные читатели. Да, этот метод бесподобен по качеству, но помимо сложности реализации (прежде всего математической) он не предоставляет результаты в режиме реального времени. А то, о чем пойдет речь ниже, работает так же быстро, как стандартный OpenGL. Здесь нужно вовремя ущипнуть себя и оговориться. Правда состоит в том, что на немолодых PC эта вещь не будет идти вовсе. Но давайте воспринимать это как очередную жертву на алтарь технического прогресса.

Санкционированный взлом

В поисках решения займемся взломом — интеллектуальным вскрытием системы изнутри. Разберемся с тем, как именно генерируется изображение, поступающее на экран (или любой другой поток вывода). Этот процесс реализует конвейер OpenGL. Последовательность действий, связанных с генерацией изображения, выполняется в системе постоянно, каждый раз при обновлении содержимого монитора. Заметим, что здесь все действия программиста относятся лишь ко второму этапу, и по-настоящему тонкая, ручная подстройка всех преобразований либо недоступна, либо требует огромных усилий. А что, если мы хотим задать свою собственную супербликовую модель освещения или сделать мягкие размытые тени? В этом случае нам понадобятся шейдеры. Шейдер  — это, как правило, небольшая программа, написанная на специальном языке и выполняемая непосредственно на графическом процессоре. Пока здесь много непонятного. Давайте разберемся последовательно.

 

Младший брат ЦП

Что находится в сердце нашего лабиринта трехмерной графики? Центральный процессор. Он отвечает за все, выполняет все запросы и требования системы. Но, присмотревшись внимательнее, мы обнаружим, что у нашего лабиринта два сердца. И бьются они отнюдь не синхронно. Речь идет о графическом процессоре, роль которого в современном компьютере необыкновенно велика.

«Образ мыслей» центрального процессора хорошо известен. Несмотря на все ухищрения и задумки великих оптимизаторов, связанные с кэш-памятью, статистическим прогнозированием выполнения команд и прочими прекрасными вещами, ЦП работает последовательно. Выполняет одну команду за другой, так как в общем случае он производит логические действия, между которыми существуют сильные взаимосвязи. То есть нельзя разорвать эти действия или нарушить последовательность их выполнения. А кроме того, ему постоянно приходится обращаться за данными в память.

Младший брат ЦП, графический процессор ГП, занимается немного другими вещами. Он обрабатывает изображения — большие объемы данных, совершает преобразования координат, растеризует, закрашивает. Вполне  очевидно, что обработка каждой вершины и каждого пиксела может осуществляться независимо от обработки всех остальных пикселов и вершин. Налицо «хлебное место» для любителей все распараллелить, что и было сделано. Графический процессор — это многоядерное устройство, которое обрабатывает данные в виде мощных потоков, а не в виде линейной последовательности байтов. Внутри каждого ядра весь поток обрабатывается параллельно. В последних моделях ГП ядра могут работать совершенно независимо, тогда как в более старых версиях они должны были в каждый момент времени выполнять одну и ту же команду. Взгляните еще раз на последовательность действий графического конвейера. На самом деле второй этап выполняется на вершинном процессоре, а пятый — на фрагментном (пиксельном). В современных ГП число таких внутренних процессоров достигает 32 (в GeForce 8600). В соответствии с этой схемой программы, выполняемые внутренними ядрами ГП, называют вершинными и фрагментными шейдерами.

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

Удивительно, но факт: люди, которые всегда в курсе последних разработок в области графических процессоров, поддержки новых шейдеров, — это не программисты, не разработчики, не инженеры. Это заядлые игроманы! Любой из них всегда знает, какую именно версию шейдеров поддерживает его собственная видеоплата, читает новости на сайтах разработчиков ГП, день и ночь ждет выпуска новых драйверов, быстрее всех их загружает и устанавливает. Применение шейдеров в играх напоминает гонку вооружений. Новейшие стратегии, квесты и прочие игры разрабатываются исключительно на гребне передовых технологий. От того, насколько хорошо обновляется твоя система, зависит, сможешь ли ты увидеть новое подобие реальности в следующей виртуальной игре. Сможешь ли ты, скажем, проскакать на коне через поле, залитое радужным светом, с каплями воды и тумана от водопадов в воздухе, любуясь развевающейся на ветру шелковистой гривой своего непарнокопытного друга. Интерес тут вполне понятен.

На такой романтичной ноте можно перейти к технической стороне вопроса.

 

Специфика программирования ГП

Рано или поздно кто-то из вас решится — долой ЦП (это, конечно, шутка), будем программировать ГП! Отлично. Осталось узнать, как.

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

Когда-то давно, в незапамятные времена, существовали (и по сей день еще живы) расширения OpenGL ARB_vertex_program и ARB_fragment_program, предоставляющие возможности для написания вершинных и фрагментных шейдеров соответственно. Они позволяли загружать шейдер по схеме, похожей на загрузку текстуры. При этом сам шейдер нужно было писать на ассемблероподобном языке, т.е. в виде последовательности инструкций, поразительно напоминающих машинные коды. Все это, конечно, оптимизма не внушает, но на волне энтузиазма разобраться с этими кодами вполне можно, а уж результат не заставит себя ждать. При этом на саму программу еще и накладывались очень жесткие ограничения. Все они вполне понятны и связаны с принципами работы ГП. Шейдер оперирует только данными, расположенными на регистрах процессора, — именно поэтому он отличается столь скоростной обработкой. Соответственно накладываются ограничения на число используемых переменных и даже на длину самого шейдера. Начиная с версии Shader Model 2.0, появившейся в DirectX 9, шейдеры стали уже достаточно длинными, научились обрабатывать числа с плавающей запятой (вещественные), что необходимо для правильного расчета освещенности и других параметров. Но такая, казалось бы, естественная и незаменимая в программировании вещь, как условные конструкции и переходы, была реализована уже заметно позже, и наиболее полно ее поддерживает лишь версия Shader Model 4.0, с которой работает DirectX 10. С чем связано это затруднение?

С проблемой асинхронности ядер на чипе графического процессора. Изначально схема была такова: ядра выполняют одну и ту же команду, но над разными данными. Весь массив данных разделяется логическим оптимальным образом на потоки, и каждый поток обрабатывается отдельным ядром. Если же в программе (шейдере) появляется условная конструкция, то на некоторых потоках она может разрешиться одним образом, на других — другим, так как входные данные различны. Программа ветвится. Что при этом происходит в ядрах? Часть из них должна пойти по одной ветке, часть — по другой. При этом должна быть аппаратно реализована возможность такой асинхронности, которая не давала бы резкого падения производительности. В современных моделях графических плат эта вещь реализована и успешно работает, что открывает большой простор для сотворения новых интересных обработчиков.

 

Поговорим о языках

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

Исторически первым шейдерным языком был RSL (RenderMan Shading Language) студии Pixar. Он создавался для реализации качественного освещения и других эффектов, необходимых для получения реалистичного изображения. Фактически RenderMan сейчас является стандартом профессионального рендеринга. Он используется, например, во всех работах самой студии Pixar.

Но наиболее интересным является написание шейдеров для использования в режиме реального времени. В полном соответствии с общим размежеванием всего поля трехмерной графики между лагерями OpenGL и DirectX существует два наиболее популярных высокоуровневых языка GLSL (OpenGL Shading Language) и HLSL (High Level Shader Language), разработанные специально для этих двух стандартов. У них схожая функциональность, и имеется даже утилита для конвертирования шейдеров из HLSL в GLSL.

High Level Shader Language был выпущен в 2002 г. компанией Microsoft. Этот язык имеет Cи-подобный синтаксис. Аналогично схеме, используемой в C#, программа на HLSL транслируется сначала в некоторый ассемблероподобный шейдерный язык, который затем компилируется в соответствии с моделью конкретного процессора. В языке зарезервировано несколько ключевых слов, отвечающих за входные данные — среди них есть константные и изменяемые. В остальном написание такого высокоуровневого шейдера мало чем отличается от написания обычной программы на Cи. Шейдеры на HLSL очень удобно встраиваются в среду DirectX — они сохраняются в виде файлов со специальным расширением и затем загружаются в программу в виде объектов класса Effect. Далее можно задать количество проходов, необходимых для правильной прорисовки эффекта, и запустить его на выполнение.

OpenGL Shading Language был введен в стандарте OpenGL 2.0 в 2004 г. Он особенно хорош тем, что поддерживается всеми производителями видеоплат. Как уже отмечалось выше, он очень близок по функциональности к HLSL, хотя некоторые субъективные наблюдатели считают его более мощным. Как и у HLSL, синтаксис GLSL очень напоминает Си (те же циклы, переходы, объявления переменных). Оптимизирована работа с векторами и матрицами, так как именно в таком виде описываются данные о трехмерном изображении (координаты и параметры вершин). Есть специальный тип глобальных переменных, отвечающих за связь шейдера с внешним миром. Загрузка шейдеров в программу на OpenGL также выглядит весьма удобно. По-видимому, язык будет расширяться по мере развития графических процессоров. Ожидается поддержка геометрических, текстурных и других видов шейдеров, еще не вполне реализованных на аппаратном уровне.

 

Идеи и возможности

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

Что умеют вершинные шейдеры? С их помощью мы меняем параметры вершин, т.е. значение нормалей, цвет, материалы и т.д. Допустим, мы изменяем значения нормалей в вершинах некоторым динамическим образом. При этом визуально, за счет освещения, которое вычисляется по направлению нормалей, поверхность объекта начинает дергаться, на ней появляются впадины и выпуклости. Форма выпуклости в стандартном случае округлая, так как она образуется в процессе интерполирования по значениям в соседних вершинах. Так можно моделировать микронеровности на поверхности объекта, например, сделать реалистичной выпуклую текстуру дерева или земли. Кроме того, этим же приемом моделируется мимика игровых персонажей. Приподнимая одни участки лица (уголки губ, кончики бровей) и смещая линии других (щек, подбородка), можно добиться правдоподобного (и низкозатратного) изменения выражения и формы лица во время разговора.

 

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

Фрагментные шейдеры позволяют реализовать множество эффектов уже в терминах конкретных пикселов, т.е. не нужно оборачиваться на будущее интерполирование и размазывание характеристики, как это всегда происходит после применения вершинного шейдера. Обработка пикселов в данном случае бывает более точной, что позволяет реализовывать различные виды освещения. Поясним конкретнее: когда мы задаем освещение в вершине, оно в процессе закраски интерполируется по грани и получается достаточно равномерный переход от одной вершины к другой. Это связано с тем, что стандартная модель OpenGL использует закраску по Гуро. Существует весьма популярная модель закраски по Фонгу, которую можно очень точно получить с помощью фрагментного шейдера, — тогда на объекте появятся яркие и четкие блики. Таким образом, можно вручную настраивать свою модель освещения для каждого объекта, усиливая правдоподобность материалов. Можно делать реалистичные размытые тени, недоступные для стандартных методов.

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

Фрагментные шейдеры используются также для изображения различных взрывов, пыли, искрящихся объектов, дыма и дождя. Стандартными методами OpenGL эти эффекты моделируются в виде системы частиц, обработка которой сильно загружает процессор. Шейдер же обрабатывается очень быстро.

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

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


Общая схема работы КОНВЕЙЕРА OPENGL

  1. На вход поступает набор вершин — они определяют объекты, которые должны появиться на сцене. К примеру, куб записывается в виде восьми вершин, а вот сфера записывается приближенно множеством точек на ее поверхности. Чем больше этих точек, тем сильнее полученное изображение похоже на сферу.
  2. Выполняются преобразования координат (сдвиги, повороты, растяжения) и вычисляются параметры вершин (цвет, вектор нормали, текстура и т.д.). В общем, выполняется все то, что описывает программист в виде команд-запросов к библиотеке OpenGL.
  3. Настраиваются внутренние параметры, и выполняется обрезание объекта по зоне видимости.
  4. Происходит растеризация. Условно говоря, вершины объектов проецируются на плоскую матрицу (фрейм-буфер), которая затем будет выведена на экран. Дальнейшая обработка идет уже в терминах пикселов (фрагментов) этой матрицы.
  5. Закрашивается матрица в соответствии с параметрами вершин, т.е. прорисовываются цвета, освещение, тени. Накладываются текстуры.
  6. Выполняются операции фрейм-буфера, изображение поступает на экран.