Оглавление Введение ...................................................................................................................................................... 4 Для кого предназначена эта книга ........................................................................................................ 4 История написания книги ....................................................................................................................... 4 Структура книги ....................................................................................................................................... 5 Требования к программному и аппаратному обеспечению ............................................................... 5 Описание компакт диска ........................................................................................................................ 5 Благодарности ......................................................................................................................................... 5 Глава 1. Введение в XNA Framework .......................................................................................................... 6 OpenGL .................................................................................................................................................... 6 DirectX .................................................................................................................................................. 7 Managed DirectX ....................................................................................................................................... 8 XNA Framework ........................................................................................................................................ 9 1.1. Создание простейшего приложения, использующего XNA Framework. ...................................11 1.2. Визуализация шахматной доски. ..................................................................................................18 1.2.1. Конфигурирование DirectX для отладки приложения. ........................................................20 1.2.2. Конфигурирование проектов в Visual Studio 2005 ................................................................24 1.2.3. Изменение размеров окна .....................................................................................................27 1.2.4. Восстановление работоспособности программы после потери устройства. ........................32 Заключение ................................................................................................................................................33 Глава 2. Визуализация примитивов. ........................................................................................................35 2.1. Работа с вершинами примитивов. ................................................................................................35 2.2. Основы визуализации примитивов. .............................................................................................36 2.3. Введение в HLSL ..............................................................................................................................37 2.3.1. Графический конвейер............................................................................................................38 2.3.2. Язык HLSL ..................................................................................................................................40 2.3.3. Использование эффектов в XNA Framework..........................................................................47 2.4. Точки (PrimitiveType.PointList). ......................................................................................................52 2.4.1. Проверка аппаратной поддержки вершинных шейдеров. .................................................58 2.4.2. Управление размером точек. .................................................................................................60 2.4.3. Визуализация набора точек....................................................................................................62 2.4.4. Управление цветом точек средствами HLSL. ........................................................................66 2.5. Отрезки ........................................................................................................................................71 2.5.1. Независимые отрезки (PrimitiveType.LineList). .....................................................................71
2.5.2. Связанные отрезки (PrimitiveType.LineStrip). ........................................................................74 2.6. Треугольники ..............................................................................................................................81 2.6.1. Несвязанные треугольники (PrimitiveType.TriangleList) .......................................................81 2.6.2. Веер треугольников (PrimitiveType.TriangleFan) ...................................................................91 2.6.3.Полоса из связанных треугольников (PrimitiveType.TriangleStrip) .......................................93 Заключение ..............................................................................................................................................109 Глава 3. Усложненные технологии визуализации. ...............................................................................110 3.1. Вывод на элементы управления .NET средствами XNA Framework. ........................................110 3.2. Полноэкранный режим. ...............................................................................................................117 3.2.1. Выбор оптимального видеорежима. ...................................................................................121 3.2.2. Получение списка доступных видеорежимов. ...................................................................123 3.2.3. Диалоговое окно выбора видеорежима. ............................................................................125 3.3. Анимация. .....................................................................................................................................139 3.3.1. Использование события Idle.................................................................................................144 3.3.2. Использование высокоточного таймера Stopwatch. ..........................................................145 3.3.3 Управление вертикальной синхронизацией........................................................................147 3.3.4. Замена циклов foreach на for. ..............................................................................................149 3.3.5. Устранение зависимости движений диска от производительности компьютера. .........153 3.4. Визуализация полупрозрачных примитивов. ............................................................................154 3.4.1. Смешивание цветов. .............................................................................................................155 3.4.2. Использование смешивания цветов для реализации эффекта полупрозрачности. .......157 3.4.3. Анимация построения фигуры Листажу. .............................................................................159 Заключение ..........................................................................................................................................162 Глава 4. Хранитель экрана .....................................................................................................................163 4.1. Реализация вращающегося диска. .............................................................................................164 4.2. Фейерверк искр. ...........................................................................................................................168 4.3. Преобразование приложения в хранитель экрана. ..................................................................173 4.4. Поддержка нескольких мониторов. ...........................................................................................177 4.5. Диалоговое окно конфигурации хранителя экрана. .................................................................179 4.5.1. Центрирование диалогового окна относительно Display Properties. ...............................183 4.6. Визуализация в окне предварительного просмотра.................................................................185 4.7. Создание дистрибутива. ..............................................................................................................190 4.7.1. Использование Custom Actions ............................................................................................192 4.7.2. Интеграция дистрибутивов .NET Framework 2.0 и XNA Framework 1.0 .............................194 Заключение ..........................................................................................................................................196
Глава 5. Вершинные шейдеры ...............................................................................................................197 5.1. Математические вычисления в HLSL ..........................................................................................197 5.1.1. Математические операторы.................................................................................................197 5.1.2. Работа с компонентами векторов ........................................................................................197 5.1.3. Математические функции ....................................................................................................198 5.1.4. Черно-белая закраска ...........................................................................................................199 5.2. NVIDIA FX Composer 2.0 ................................................................................................................202 5.2.1. Формат COLLADA 1.4.1...........................................................................................................203 5.2.2. Знакомство с интерфейсом FX Composer 2.0 ......................................................................205 5.2.3. Создание нового проекта .....................................................................................................207 5.2.4. Анализ производительности эффекта. ................................................................................211 5.3. Введение в языки Vertex Shader .................................................................................................214 5.3.1. Регистры .................................................................................................................................215 5.3.2. Команды. ................................................................................................................................220 5.3.3. Разбираем код простого шейдера .......................................................................................225 5.4. Передача параметров в эффект ..................................................................................................227 5.4.1. Работа с параметрами эффектов в XNA Framework ...........................................................230 5.5. Шейдерный фейерверк ...............................................................................................................232 5.5.1. Моделирование вращения диска ........................................................................................233 5.5.2. Оператор if языка HLSL .........................................................................................................243 5.5.3. Искры ......................................................................................................................................247 5.5.4. Анализ производительности приложения ..........................................................................263 Заключение ..........................................................................................................................................264 Заключение ..............................................................................................................................................265
Введение До недавнего времени создание высокопроизводительных графических приложений для платформы .NET было весьма нетривиальной задачей. Классические графические API такие как OpenGL и DirectX, невозможно непосредственно использовать в .NET приложениях, так как их подключаемые файлы рассчитаны на язык C++. Конвертация этих файлов в сборки .NET тоже не является решением проблемы: активное использование в API OpenGL и DirectX указателей и специфичных возможностей C++ добавляет множество головной боли разработчикам, вынуждая активно использовать unsafe-код и нетривиальный маршалинг, не говоря о том, что на .NET зачастую переводится лишь “ядро” графического API без вспомогательных библиотек и примеров. Альтернативный вариант с выносом графической подсистемы приложения в отдельную сборку, разрабатываемую на C++/CLI с использованием родных заголовочных файлов API, тоже далек от идеала: C++/CLI предъявляет заметно более жесткие требования к квалификации программиста, но не все компании могут позволить себе нанять таких высокооплачиваемых разработчиков. Для устранения образовавшегося пробела корпорация Microsoft выпустила XNA – инструментарий разработки кроссплатформенных игровых приложений для .NET, ориентированный на небольшие команды разработчиков. В основе XNA лежит библиотека XNA Framework – набор высокопроизводительных управляемых сборок .NET с множеством классов для работы с видео и аудио подсистемой компьютера, а так же периферийными устройствами вроде джойстиков. На платформе Windows библиотека XNA Framework работает через DirectX, но это отнюдь не .NET обертка над COM-объектами DirectX. Местами некоторые сходства, конечно, прослеживаются, но в целом иерархия классов была значительно переработана с учетом возможностей платформы .NET, в результате чего XNA Framework стал заметно проще в использовании. Более того, XNA Framework предоставляет разработчику множество высокоуровневых средств для использования в приложениях разнообразного контента: изображений, текстур, моделей, аудио и т.д. и т.п. К тому же классы XNA Framework не завязаны на специфичные возможности Windows, что позволяет теоретически запускать их на любой платформе, поддерживающей .NET Framework. Всѐ это делает XNA очень привлекательным для использования не только в игровых, но и более “серьѐзных” приложениях, требовательных к производительности видеоподсистемы компьютера: редакторов уровней, геоинформационных систем, CAD-приложений и т.п. Фактически всѐ, что требуется от разработчика для получения доступа к возможностям XNA: просто подключить к существующему проекту сборки XNA Framework. Но есть и небольшая ложка дегтя – использование XNA Framework совместно с Windows Forms несколько сложнее по сравнению с чисто игровыми приложениями, а текущая документация XNA Framework практически не содержит информации по такому “нештатному” применению XNA Framework.
Для кого предназначена эта книга Я адресую эту книгу студентам и начинающим разработчикам, которые хотят использовать в своих проектах высокопроизводительную графику. Книга рассчитана на читателей, уже знакомых с основами C# и платформы .NET.
История написания книги Изначально я планировал написать книгу под рабочим названием “Профессиональное программирование трехмерной графики на Managed DirectX 2.0”. Но после детального анализа российского книжного рынка оказалось, что в России практически отсутствуют книги по Managed DirectX, ориентированные на начинающих. Иными словами, на российском рынке ещѐ не сформировался спрос на продвинутые книги по Managed DirectX. Поэтому , не мудрствуя лукаво, я решил написать учебный курс по компьютерной графике с упором на практическое использование Managed DirectX, после чего перейти к более сложному материалу. На первых порах всѐ шло по плану, я уже приступил к написанию главы, посвященной моделям освещения, как среди ясного неба грянул гром: работы над бета версией Managed DirectX 2.0 были прекращены, а приемником Managed DirectX 2.0 стал XNA Framework. Ситуация была тупиковой. Мне оставалось только одно: адаптировать материал для XNA Framework. Однако после детального знакомства с XNA Framework оказалось, что он значительно отличается от Managed DirectX, поэтому адаптированная книга получилась бы очень посредственной, что-то вроде книг по Visual C++ .NET, в которых собственно .NET посвящена одна последняя глава. В конце концов, я решился начать писать с нуля новую книгу по XNA Framework, но, к сожалению, из-за жесткого дефицита времени удалось написать только первые 5 глав, а после моего переезда в Москву написание книги окончательно встало. Честно говоря, я долго думал, стоит ли издавать незаконченную книгу, но после общения с читателями “бета-версий” глав пришел к выводу, что в условиях дефицита
русскоязычной литературы по XNA Framework убирать этот уникальный материал в чулан было бы в высшей степени неразумно.
Структура книги Как говорилось выше, книга состоит из пяти глав. Первая глава знакомит читателя с XNA Framework. В ней рассматривается подключение XNA Framework к проекту Visual Studio, визуализация на поверхности формы и корректная обработка изменений размеров окна. Вторая глава посвящена визуализации базовых примитивов XNA Framework. Так как в XNA отсутствует фиксированный графический конвейер, в ней так же затрагиваются основы программируемого графического конвейера. В третьей главе рассматриваются более сложные вопросы визуализации: использование полноэкранного режима, плавная анимация примитивов и имитация прозрачности. В четвертой главе весь ранее изученный материал сводится воедино на примере создания полноценного хранителя экрана с дистрибутивом, при необходимости автоматически инсталлирующим на компьютер пользователя XNA Framework. Пятая глава начинает следующий виток спирали, но уже на более детальном уровне. Здесь снова затрагивается тема программируемого графического конвейера, но уже на значительно более глубоком уровне; рассматриваются язык HLSL, основы ассемблеро-подобного языка Vertex Shader 1.1 и интегрированная среда разработки FX Composer 2.0, значительно облегчающая разработку и отладку шейдеров.
Требования к программному и аппаратному обеспечению Все примеры книги были созданы в Visual Studio 2005 Pro SP1, однако для запуска большинства примеров вполне достаточно и Visual C# 2005 Express SP1. Так как XNA Framework не поддерживает фиксированный графический конвейер, видеокарта должна обязательно иметь аппаратную поддержку пиксельных шейдеров хотя бы версии 1.1. Аппаратная поддержка вершинных шейдеров не требуется, так что примеры книги корректно работают даже на интегрированных видеокартах Intel GMA 9xx.
Описание компакт диска Компакт диск, прилагаемый к книге, содержит следующие каталоги: DOC – документация, на которую имеются ссылки в тексте книги. Examples – проекты примеров книги, сгруппированные по главам. NVIDIA – IDE для разработки шейдеров NVIDIA FX Composer 2.0. Tools o Debug View – утилита Марка Руссиновича для просмотра отладочных сообщений. o .NET Reflector – известная утилита для дизассемблирования сборок .NET. o RightMark 3D – утилита для тестирования производительности видеокарт. Visual C# 2005 Express – требуется для корректной установки XNA Game Studio 1.0 Express. Visual C# 2005 Express SP1 – обновления для Visual C# 2005 Express. XNA Game Studio Express 1.0 – версия XNA, используемая примерами книги. XNA Game Studio 2.0 – текущая версия XNA Game Studio. Для компиляции примеров книги необходимо установить Visual C# 2005 Express, Visual C# 2005 Express SP1 и XNA Game Studio Express 1.0, после чего скопировать папку Examples на локальный диск, не забывая снять с еѐ содержимого атрибут read only.
Благодарности В ходе работы над книгой мне приходилось общаться с людьми, которые давали мне ценные советы и оказывали моральную поддержку. Я бы хотел поблагодарить: Алексея Кряжева (Codemasters Software), Игоря Рыбинского (BHV), Филиппа Герасимова (NVIDIA), Андрея Крючкова (Microsoft), Александра Ложечкина (Microsoft), Олега Михайлика, Евгения Маркова (NVIDIA), Кирилла Дмитриева (NVIDIA), Михаила Фарленкова (XNA Dev), Романа Никитина, Викторию Жислину (Intel) и Николая Семенова (стажер Intel, СПбГУАП). Отдельная благодарность выражается Геннадию Ригеру (AMD), Юрию Уральскому (NVIDIA) и Дмитрию Набойченко (SolarWind), оказавшим неоценимую помощь при написании книги.
Глава 1. Введение в XNA Framework Первые версии Windows позволяли программисту работать с видеоподсистемой компьютера лишь посредством стандартного интерфейса GDI, предоставляющему программисту унифицированный доступ к различным устройствам вывода графической информации будь то видеокарта, принтер или плоттер. Интерфейс GDI очень прост в использовании – приложение работает с некоторым виртуальным устройством, а GDI самостоятельно транслирует все вызовы приложения в команды конкретной видеокарты или принтера. Преимущества данного подхода очевидны. Например, добавив в приложение всего несколько строк кода, вы можете с легкостью перенаправить вывод с экрана монитора на принтер и получить нужный результат. Кроме того, работа с виртуальным устройством не позволяет некорректно написанному приложению нанести какой-либо ощутимый ущерб стабильности операционной системе и повредить данные других приложений. Как всегда, недостатки интерфейса GDI являются продолжением его достоинств. Любому человеку, даже поверхностно знакомому с устройством персонального компьютера, ясно, что, к примеру, видеокарта NVIDIA GeForce 5900 Ultra и лазерный принтер Canon Laser Shot LBP-1120 являются абсолютно разными устройствами. Соответственно, при проектировании универсального интерфейса для работы с данными устройствами неминуемо придѐтся жертвовать эффективностью. Это не особо критично для офисных приложений, работающих с достаточно простыми изображениями и не требовательных к скорости обновления экрана. Однако для целого класса приложений (игры, системы виртуальной реальности, пакеты 3D моделирования), критичных к производительности видеоподсистемы компьютера, накладные расходы GDI оказались неприемлемыми. В результате разработчики этих приложений не стремились переносить свои разработки с DOS на Windows, что отнюдь не способствовало росту популярности операционной системы Windows.
OpenGL Так как операционная система без развлекательных приложений вряд ли смогла бы добиться широкой популярности среди домашних пользователей, Microsoft включала сначала в Windows NT 3.5, а затем и в Windows 95 OSR2 поддержку OpenGL – известного кроссплатформенного API для разработки трехмерных графических приложений реального времени. В те времена OpenGL справедливо считался флагманом индустрии трехмерной графики реального времени. Тем не менее, у него все же был ряд недостатков: Так как OpenGL предназначен исключительно для работы с графикой, он решил лишь проблемы низкой производительности графической подсистемы Windows. Работа с аудиоподсистемой компьютера по-прежнему осуществлялась с использованием стандартного медленного интерфейса MCI1. Аналогичным образом обстояли дела и с получением информации от устройств ввода: клавиатуры, мыши и джойстиков. OpenGL является кросплатформенным API, не привязанным к операционной системе. В результате в нем отсутствуют какие-либо средства для создания окон, загрузки текстур и моделей из файлов и т.д., так как реализация данной функциональности неминуемо бы ограничила переносимость этого API. Подобные особенности несколько усложняют разработку приложений, предназначенных исключительно для платформы Windows. OpenGL является полностью открытым API, не имеющим единого хозяина. Развитие OpenGL координируется наблюдательным комитетом по архитектуре (ARB), в который входят ведущие лидеры индустрии, такие как Intel, Microsoft, AMD, NVIDIA, SGI, 3D Labs, Evans & Sutherland и т.д. Такое число участников зачастую приводит к конфликтам внутри комитета, последствия которых хорошо описаны в известной басне И.А. Крылова “Лебедь, рак и щука”. 1
Media Control Interface – интерфейс управления мультимедийными устройствами. Содержит набор стандартных команд, позволяющих осуществлять воспроизведение и запись файлов мультимедийных ресурсов. Информацию о MCI можно найти, к примеру, в [К.4].
Кроме того, использование открытого API, пускай и лучшего в индустрии, в качестве одного из краеугольных компонентов, мягко говоря, не отвечает интересам политики Microsoft. Поэтому нет ничего удивительно в том, что параллельно с интеграцией OpenGL в Windows, Microsoft работала над собственным API для разработки мультимедийных приложений.
DirectX Первым игровым API, разработанным Microsoft, стал, стал WinG [С.1]. Это был достаточно примитивный API предназначенный для работы исключительно с двухмерной графикой реального времени в операционных системах Windows 3.1 и Windows 95. Видеорежимы с количеством цветов более 256 не поддерживались. Единственным преимуществом WinG была более высокая производительность, чем у GDI. Впрочем, на фоне DOS и OpenGL 1.1 оно выглядело весьма спорным. После неудачи с WinG стало ясно, что Microsoft вряд ли сможет в одиночку в короткие сроки разработать конкурентоспособный API для программирования графических приложений реального времени. В результате было принято решение купить британскую компанию RenderMorfics и перенести еѐ библиотеку Reality Lab на платформу Windows. Так появился Game SDK, позже переименованный в DirectX. Из-за сжатых сроков Microsoft не смогла создать на основе Reality Lab полноценный масштабируемый API с заделом на будущее, в результате чего на рынке началась настоящая чехарда версий DirectX, для получения представления о масштабах которой достаточно посмотреть на частоту появления новый версий DirectX (таблица 1.1). Нетрудно заметить, что каждый год выходило не менее одной версии DirectX, причѐм новая версия содержала множество кардинальных изменений, в результате чего оказывалась несовместимой с более старой версией. Положение стабилизировалось лишь в 2002-м году с выходом 9-й версии DirectX. С тех пор были выпущены лишь три новых редакции DirectX с незначительными изменениями, после чего Microsoft перешла к выпуску обновлений к DirectX, устраняющих мелкие недочеты, в темпе одно обновление каждые два месяца. П р им еч а н ие Любопытно, что графическая подсистема DirectX смогла достичь функциональность OpenGL 1.1 (существовавшего ещѐ до появления DirectX) лишь к 7-й версии. Впрочем, к 9-й версии возможности OpenGL и DirectX сравнялись, после чего наметилась тенденция к технологическому отставанию OpenGL от DirectX.
Таблица 1.1. Даты выхода версий DirectX Версия DirectX
Дата выхода
DirectX 1.0
1995
DirectX 2.0
1996
DirectX 3.0 / 3.0a
1996
DirectX 5.0 / 5.1 / 5.2
1997-1998
DirectX 6.0 / 6.1
1998-1999
DirectX 7.0 / 7.0a / 7.1
1999
DirectX 8.0 / 8.1
2000-2001
DirectX 9.0 / 9.0a / 9.0b / 9.0c + Updates
2002-2006
Что представляет собой DirectX? Это высокопроизводительная мультимедийная библиотека для программирования приложений требовательных к производительности видеоподсистемы, аудиосистемы и системы ввода-вывода компьютера. В основе DirectX лежит набор COM2-интерфейсов, предоставляющих программисту доступ к аппаратному обеспечению компьютера. Эти интерфейсы разработаны Microsoft в тесном сотрудничестве с ведущими производителями аппаратных устройств, таких как Intel, ATI, NVIDIA, 2
Component Object Model – модель компонентных объектов. Определяет стандарты интерфейсов API и бинарные стандарты для связи объектов, не зависящих от языка программирования. Каждый объект COM имеет один или несколько интерфейсов, представляющих собой таблицы функций, связанных с этим объектом. В продаже и свободном доступе имеется множество литературы, посвящѐнной COM, начиная с [К.9] и заканчивая MSDN.
Creative и т.д. Поэтому интерфейсы DirectX очень близки к современному аппаратному обеспечению и фактически связаны с аппаратным обеспечением через тонкую прослойку драйверов, минуя стандартные интерфейсы Win32, такие как GDI и MCI. В DirectX 9 интерфейсы сгруппированы в три специализированных компонента 3 (рисунок 1.1): DirectX Graphics, отвечающий за работу с двухмерной и трѐхмерной графикой. DirectInput, предназначенный для работы с устройствами ввода: мышью, клавиатуры, джойстиками с обратной связью и т.п. DirectSound, используемый для работы со звуковым оборудованием: звуковыми картами, в том числе и с поддержкой трѐхмерного звука, MIDI-синтезаторами и т.п.
Рисунок 1.1. Упрощѐнная схема взаимодействия приложения с устройствами при использовании DirectX
Managed DirectX После выхода платформы .NET Framework встал вопрос об использование трехмерной графики в приложениях, написанных на управляемых языках вроде C#. Дело в том, что COM-интерфейсы компонентов DirectX проектировались в расчѐте на использование в программах на языке C++, в результате чего они активно используют специфические возможности C++, к примеру, указатели. Поэтому, хотя язык C# и позволяет использовать COM-компоненты, применение интерфейсов DirectX в программах на C# сопряжено с рядом проблем: применение же указателей в C# не приветствуется и является плохим тоном программирования на платформе .NET, указатели могут использоваться только в unsafe-блоках, что затрудняет чтение программы и повышает вероятность ошибок. Вдобавок, COM-компоненты не могут использовать преимущества инфраструктуры .NET такие как автоматическая сборка мусора и обработка ошибок с использованием исключений. Чтобы облегчить жизнь разработчикам приложений для платформы .NET, Microsoft включила в состав 9-й версии DirectX надстройку над COM-интерфейсами DirectX: Managed DirectX. В октябре 2005 года была анонсирована вторая версия Managed DirectX, которая была фактически переписана с нуля с учѐтом новых возможностей платформы .NET 2.0, в частности, Managed DirectX стал активно использовать обобщенные (Generic) классы. Managed DirectX 2.0 является тонкой надстройкой над DirectX, размещѐнной в сборке Microsoft.DirectX.dll. Сборка Microsoft.DirectX.dll удовлетворяет всем требованиям платформы .NET 2.0 и не привязана к конкретному языку программирования. Соответственно она может с одинаковой лѐгкостью использоваться в любой .NET-совместимом языке программирования вроде Microsoft Visual Basic, Microsoft Visual C#, Microsoft Visual J#, Iron Python и т.п. Как и оригинальный DirectX, Managed DirectX состоит из трѐх компонентов: Direct3D Graphics, DirectInput и DirectSound. Каждому компоненту соответствует одно или несколько пространств имѐн (таблица 1.2), содержащих классы и структуры данного компонента. При этом 3
В действительности в составе DirectX имеется ещѐ четыре компонента: DirectDraw (работа с двухмерной графикой), DirectMusic (проигрывание фоновой музыки), DirectPlay (работа с сетью) и DirectShow (проигрывание видеофайлов). Однако эти компоненты не рекомендуются к использованию, так как они объявлены устаревшими и по видимости будут исключены из будущих версий DirectX.
хорошо прослеживается соответствие между классами DirectX и соответствующими COM-интерфейсами неуправляемого DirectX. К примеру, COM-интерфейсу IDirect3D9 соответствует брат-близнец класс Microsoft.DirectX.Direct3D.Device, интерфейсу IDirect3DTexture9 – класс Microsoft.DirectX.Direct3D.Texture и так далее. Более того, при должной сноровке при изучении Managed DirectX вполне можно пользоваться документацией по C++ и наоборот4. Таблица 1.2. Пространства имѐн Managed DirectX 2.0 Пространство имѐн
Соответствующий компонент DirectX
Описание
Microsoft.DirectX
Используется всеми компонентами DirectX
Содержит базовый набор классов, общий для всех компонентов: векторы, матрицы, кватернионы и т.п.
Microsoft.DirectX.Generic
Используется всеми компонентами DirectX
В этом пространстве имѐн расположены все обобщѐнные классы
Microsoft.DirectX.Direct3D
DirectX Graphics
Отвечает за работу с 3D графикой
Microsoft.DirectX.Direct3D.CustomVertex
DirectX Graphics
Содержит структуры форматов вершин
Microsoft.DirectX.DirectInput
DirectInput
Работа с устройствами ввода
Microsoft.DirectX.XInput
DirectInput
Работа с устройствами игровой приставки XBOX
Microsoft.DirectX.DirectSound
DirectSound
Работа со звуком, в том числе и трѐхмерным.
типовых
ввода
Однако Beta версия Managed DirectX 2.0 не была доведена до Release, став частью значительно более амбициозного проекта XNA Framework.
XNA Framework В 2005-м году в продажу поступила игровая приставка Microsoft следующего поколения – XBOX 360. На этот раз Microsoft решила отказаться от использования процессоров привычной архитектуры x86 в пользу процессора PowerPC, не совместимого с x86. Сама архитектура игровой приставки так же значительно отличалась от персонального компьютера, работающего под управлением операционной системы Windows. Таким образом, впервые в истории у Microsoft оказалось две несовместимых игровых платформы: Windows и XBOX 360. Это обстоятельство ощутимо осложнило жизнь разработчикам игр, так как написание приложения с поддержкой обоих платформ фактически сводится к написанию отдельных приложений для каждой платформы, что под силу лишь достаточно крупным конторам. Подобная изоляция двух платформ не устраивала Microsoft, поэтому возникла необходимость создания инструментария позволяющего небольшим конторам и начинающим разработчикам создавать кроссплатформенные приложения, работающие как на платформе Windows, так и на XBOX 360. В качестве основы было решено использовать платформу .NET: как известно, .NET-приложения компилируются в промежуточный язык IL, а финальная компиляция в машинный код происходит только при запуске приложения на конкретной системе. Таким образом, .NET-приложению, в общем-то, безразлично, какой процессор в данный момент установлен в системе. Но на практике все оказалось несколько сложнее: 1. .NET Framework содержит мощную библиотеку классов на все случаи жизни, включая разработку приложений баз данных, web-сервисов, web-сайтов. Разумеется, данная функциональность является, мягко говоря, несколько избыточной для игровой приставки. Кроме того, ряд классов .NET Framework сильно привязаны к операционной системе Windows (пространства имен System.Windows.Forms, System.Drawings), а перенос Windows на игровую приставку является весьма сомнительной затеей. 2. Managed DirectX, является достаточно тонкой настройкой над DirectX. А так как DirectX – это один из компонентов платформы Windows, Managed DirectX автоматически оказывается непереносимым API, привязанным к платформе Windows. Кроме того, классы и структуры Managed DirectX активно используют функциональность из пространств имен System.Windows.Forms и System.Drawing. 4
Это очень важный нюанс, так как в настоящее время классический DirectX содержит гораздо более подробную документацию.
Первая проблема была решена путѐм реализации на XBOX лишь подмножества классов .NET Framework, известного как .NET Compact Framework. Для решения второй проблемы был разработан XNA Framework – высокопроизводительный кроссплатформенный API для разработки графический приложений для платформ Windows и XBOX 360. Условно все компоненты XNA Framework можно разделить на 4 уровня абстракции (рисунок 1.2): Platform (Платформа) – самый нижний уровень, содержащий платформо-зависимые API, такие как неуправляемый DirectX. В подавляющем большинстве случаев приложение может нечего не знать о существовании этого уровня, используя компоненты более высоких уровни абстракции. Более того, прямое обращение уровню Platform неминуемо сузит диапазон платформ, поддерживаемых приложением: не исключено, что Microsoft в будущем добавит поддержку XNA Framework и в операционные системы для карманных устройств. Core Framework (Основной Каркас) – нижний платформо-независимый уровень XNA, обеспечивающий базовую функциональность. Размещается в сборке Microsoft.Xna.Framework.dll и содержит 5 компонентов: Graphics (работа с графикой), Audio (работа со звуком), Input (работа с устройствами ввода-вывода), Math (математические расчеты), Storage (работа с файловой системой). Классы и структуры каждого компонента сгруппированы в пространства имен (таблица 1.3). На платформе Windows первые три компонента (Graphics, Audio, Input) являются надстройками над DirectX, а компонент Storage – надстройкой над классами .NET Framework для работы с файловой системой. Однако следует всегда помнить о том, что на других платформах всѐ может обстоять совершенно иначе. Extended Framework (расширенный каркас) – набор высокоуровневых классов, решающих типовые задачи, встающие перед разработчиком игр: инициализация графического устройства, организация цикла обработки сообщений, экспорт моделей и текстур из графических редакторов. По сути Extended Framework можно считать универсальным игровым движком (Game Engine) начального уровня. Размещается в сборке Microsoft.Xna.Framework.Game.dll. Game – собственно приложение пользователя, то есть наши с вами программы. К слову, в комплект XNA входит несколько простых игр (Starter Kits), которые можно использовать в качестве заготовок для своих приложений.
Games
Extended Framework (Microsoft.Xna.Framework.Game.dll)
Компоненты: Application Model, Content Pipeline
Core Framework (Microsoft.Xna.Framework.dll)
Компоненты: Graphics, Audio, Input, Math, Storage
Platform Компоненты: Direct3D, XACT, XINPUT, XContent и т.д. Рисунок 1.2. Уровни XNA Framework
Таблица 1.3. Пространства имен сборки Microsoft.Xna.Framework.dll Пространство имен
Соответствующий компонент XNA
Назначение
Microsoft.Xna.Framework
Math
Математические расчеты: матричная алгебра, аналитическая геометрия, проверка столкновений и т.д. В Managed DirectX эта функциональность (в урезанном виде)
реализовывалась посредством библиотеки D3DX, являющейся частью DirectX. XNA Framework выполняет математические расчеты собственными средствами, что в некоторых случаях несколько повышает производительностью благодаря отсутствию накладных расходов взаимодействия с COM. Microsoft.Xna.Framework.Graphics
Graphics
Работа с графикой
Microsoft.Xna.Framework.Graphics.PackedVector
Graphics
Работа с упакованными векторами. Примером упакованного вектора является 32х битное число, содержащее информацию о яркости красной, синей и зеленой компонентах цвета.
Microsoft.Xna.Framework.Audio
Audio
Работа со звуком
Microsoft.Xna.Framework.Input
Input
Работа с устройствами ввода (клавиатура, мышь, джойстики).
Microsoft.Xna.Framework.Storage
Storage
Работа с файловой системой текущей платформы: загрузка и сохранение настроек приложения, “сохраненных игр” (Save Games) и т.д.
Ничего страшного, если у вас на первых порах будет рябить в глазах от обилия компонентов. По мере изучения XNA Framework всѐ встанет на свои места. В первой главе мы познакомимся с некоторыми классами пространства имен Microsoft.Xna.Framework.Graphics, и научимся использовать их для визуализации относительно простых двухмерных изображений.
1.1. Создание простейшего приложения, использующего XNA Framework. Как известно, лучший способ получить представление о новой технологии – написать с еѐ помощью простейшее приложение. Так мы и поступим. Наше первое приложение, использующее XNA Framework, будет просто закрашивать форму синим цветом (рисунок 1.3). Для создания GUI5-интерфейса мы воспользуемся библиотекой Windows Forms, являющуюся стандартом для платформы .NET.
Рисунок 1.3. Наша первое приложение(Ex01), использующее XNA.
5
Graphic User Interface – графический пользовательский интерфейс
Для начала запустите Microsoft Visual Studio 2005 и создайте новый проект GUI–приложения для платформы Windows (File | New | Project...). В раскрывшемся окне выберите Visual C# | Windows | Windows Application, введите название приложения, снимите флажок Create directory for Solution и нажмите Ok6. Переименуйте файл формы из Form1.cs в 7 MainForm.cs . Следующий шаг – подключение сборки Microsoft.Xna.Framework.dll, содержащий компоненты слоя Core Framework, включая необходимый нам компонент Graphics.Для подключения сборки щѐлкните правой кнопкой мыши на узле Reference в окне Solution Explorer и выберите в контекстном меню пункт Add Reference... (рисунок 1.4). В открывшемся окне выберете сборку Microsoft.Xna.Framework и нажмите кнопку Ok (рисунок 1.5).
Рисунок 1.4. Вкладка Solution Explorer
6 7
В простых приложениях я предпочитаю следовать принципу “один проект – одно решение”. Во всех примерах книги главная форма приложения будет называться MainForm.cs.
Рисунок 1.5. Окно Add Reference
Теперь мы можем приступать к собственно написанию программы. Откройте окно редактирования исходного кода, щелкнув правой кнопкой мыши по форме и выбрав пункт View Code контекстного меню (либо нажав на кнопке View Code в верхней части окна Solution Explorer). Так как мы будем активно использовать классы из пространства имен Microsoft.Xna.Framework.Graphics, было бы логично добавить в начало программы следующую строку: using Microsoft.Xna.Framework.Graphics;
В XNA Framework все низкоуровневые графические операции выполняются с использованием класса GraphicsDevice, инкапсулирующим графическое устройство (трѐхмерный ускоритель). Конструктор класса GraphicsDevice объявлен следующим образом: public GraphicsDevice(GraphicsAdapter adapter, DeviceType deviceType, IntPtr renderWindowHandle, CreateOptions creationOptions, params PresentationParameters[] presentationParameters);
где adapter – экземпляр класса GraphicsAdapter, соответствующей используемой видеокарте (многие современные компьютеры содержат две и более видеокарты). Для указания видеокарты по умолчанию достаточно передать в качестве данного параметра значение статического свойства GraphicsAdapter.DefaultAdapter. deviceType – тип устройства, задаваемый с использованием перечислимого типа DeviceType (таблица 1.4). На практике обычно используется значение DeviceType.Hardware. renderWindowHandle – дескриптор окна или элемента управления, который будет использоваться для вывода информации. creationOptions – набор битовых флагов перечислимого типа CreateOptions, задающих режим работы устройства (таблица 1.5). В нашем случае мы будем использовать режимы CreateOptions.SoftwareVertexProcessing и CreateOptions.SingleThreaded. PresentationParameters – набор структур PresentationParameters, описывающих представление данных на экране монитора. Каждому монитору соответствует своя структура PresentationParameters. Так как наши приложение будет осуществлять вывод только на один
монитор, мы ограничимся одной структурой PresentationParameters. В таблице 1.6 приведено описание некоторых свойств структуры PresentationParameters. Если попытка создания устройства заканчивается неудачей, конструктор генерирует исключение. Подобная ситуация возникает, к примеру, при попытке создания устройства с использованием флага CreateOptions.HardwareVertexProcessing на видеокарте, не имеющей аппаратных вершинных процессоров. Примерами таких видеокарт являются Intel GMA 900 и Intel GMA950, интегрированные в чипсеты i915G и i945G соответственно. Так как флаг HardwareVertexProcessing влияют исключительно на выполнение вершинных шейдеров, в то время как примеры этой главы не используют эту функциональность, применение данного флага некоим образом не повлияет на производительность наших приложений и лишь неоправданно увеличит требования к видеокарте. Поэтому в примерах первой главы книги мы ограничимся использованием лишь флага SoftwareVertexProcessing. Таблица 1.4. Значения перечислимого типа DeviceType Значение
Описание
Hardware
Аппаратное устройство
Reference
Устройство эмулируется средствами DirectX SDK. Обычно используется для эмуляции функциональности, не поддерживаемой текущей видеокартой (например, эмуляция пиксельных шейдеров8 на GeForce2). Правда, такую эмуляцию реально использовать лишь в отладочных целях, так как производительность приложений в этом режиме редко превышает один кадр в секунду.
NullReference
Нуль-устройство, игнорирующее все поступающие команды (наподобие устройства NUL в MS-DOS и Windows). Может использоваться для оценки производительности приложения на бесконечно быстрой видеокарте.
Таблица 1.5. Значения перечислимого типа CreateFlags Значение
Описание
HardwareVertexProcessing
Аппаратная обработка вершин средствами GPU9
SoftwareVertexProcessing
Программная обработка вершин средствами CPU.
MixedVertexProcessing
Смешанная обработка вершин. Позволяет приложению самостоятельно переключаться между программной и аппаратной обработкой вершин. Полезна, к примеру, при использовании вершинных шейдеров версии 2.0 на GeForce3, который аппаратно поддерживает только вершинные шейдеры версии 1.1. В этом случае вершинные шейдеры версии 1.1 можно выполнять аппаратно, а версии 2.0 – в режиме программной эмуляции.
NoWindowChanges
Устройство не будет автоматически восстанавливаться после потери фокуса окном и т.д. Все эти действия ложатся на плечи программиста. Данное значение применяется очень редко.
SingleThreaded
Увеличивает производительность за счет отключения критических секций, позволяющих одновременно обращаться к устройству из нескольких потоков. Рекомендуется всегда по возможности использовать этот флаг.
SinglePrecision
Переключает математический сопроцессор в режим пониженной точности. Все вычисления с плавающей точкой, включая использующие тип double, будут выполнять с точностью 7 знаков. Более подробная
8 9
Основы вершинные и пиксельных шейдеров будут рассмотрены в разделе 2.3. Graphic Processor Unit – графический процессор, установленный на видеокарте.
информация об этом режиме приведена во врезке.
Таблица 1.6. Некоторые свойства структуры PresentationParameters Поле
Описание
bool IsFullScreen
При выводе на поверхность компонента или формы этому свойству необходимо присвоить значение false. Если же приложение является полноэкранным, то используется значение true.
int BackBufferCount
Задаѐт количество вспомогательных буферов, используемых для борьбы с эффектом мерцания при смене кадров. При использовании одного вторичного буфера изображение сначала рисуется во вспомогательном буфере, после чего уже готовое изображение копируется в экранный буфер. Этот процесс называется двойной буферизацией. Буферизация, использующая два вспомогательных буфера, называется тройной. Более подробно различные типы буферизации будут рассмотрены в соответствующих разделах книги. А пока мы будем использовать двойную буферизацию, присваивая полю BackBufferCount значение 1.
int BackBufferWidth
Ширина вспомогательных буферов в пикселях. Если этот параметр равен 0, то конструктор метода Device рассчитывает его автоматически, полагая равным ширине клиентской области окна10.
int BackBufferHeight
Высота вспомогательных буферов в пикселях. Если этот параметр равен 0, то конструктор метода Device рассчитывает его автоматически, полагая равным высоте клиентской области окна.
Direct3D.SwapEffect SwapEffect
Указывает, каким образом осуществляется переключение буферов при двойной и тройной буферизации. Подавляющее большинство программ присваивают этому полю значение SwapEffect.Discard – в этом случае видеокарта сама выбирает режим переключения буфера, наиболее подходящий в конкретной ситуации.
Точность вычислений с плавающей точкой процессоров архитектуры x86 Блок вычислений с плавающей точкой (FPU) процессоров x8611 содержит восемь 80-ти битных регистров общего назначения, используемых для хранения операндов и результатов вычислений. Иными словами, независимо от используемых типов данных сопроцессор всегда оперирует с 80-ти битным форматом с плавающей точкой, известным как extended double. Если операнды имеют меньшую разрядность (например, используется тип float), то они автоматически конвертируются в 80-ти битный формат, а результат перед копированием в память переводится обратно в 32-х битный формат float. Так как подобная точность вычислений оказывается излишней, в одном из управляющих регистров процессора (CW) имеются два бита (PC), управляющие точностью вычислений. В зависимости от значения битов, задающихся при запуске приложения, вычисления выдуться с 7-ю, 16-ю или 19-ю значащими цифрами. Ещѐ раз хочу подчеркнуть важную деталь. Формат чисел в регистрах всегда остаѐтся 80-ти битным, просто с понижением точности младшие разряды числа с плавающей точкой могут содержать недостоверную информацию. Два бита PC регистра CW устанавливаются при запуске потока (thread) и, как правило, не меняются в процессе выполнения приложения. В то же время, параметр CreateOptions.SinglePrecision приказывает конструктору класса GraphicsDevice изменить служебный регистр CW таким образом, чтобы установить внутри сопроцессора точность вычислений 7 знаков. В результате, после вызова данного метода вычисления, включая использующие типы double, будут выполняться с 7-ю значащими знаками. Правда существуют и исключения: так, к примеру, биты PC регистра CW оказывают влияние на точность сложения,
10
Часть окна, используемая приложением для вывода информации. В клиентскую область окна не входят заголовок окна, рамки по краям окна и т.д. 11 До появления i486DX этот блок располагался в отдельной микросхеме, называемой математическим сопроцессором (8087, 80287, 80387).
вычитания, умножения, деления и вычисления квадратного корня, но совершенно не затрагивают точность команд вычисления синуса и косинуса. В общем, здесь очень много различных тонкостей, но разбираться в них, в общем-то, не нужно. Достаточно запомнить, что флаг CreateOptions.SinglePrecision таит много подводных камней, поэтому применять его стоит только при реальной необходимости поднять производительно математической подсистемы любой ценой. В остальных случаях вы лишь повысите вероятность возникновения различных трудно обнаруживаемых ошибок. Ведь никто не сможет гарантировать, что одна из используемых вами библиотек не начнѐт вести себя как-то странно, столкнувшись с аномально низкой точностью вычислений, использующих типы double. Например, пониженная точность может очень негативно сказаться на качестве генератора псевдослучайных чисел. И так, для создания графического устройства мы должны объявить в тексте класса формы поле класса GraphicsDevice: GraphicsDevice device=null;
Затем в обработчик события Load формы необходимо вставить код создания нового экземпляра класса 12 GraphicsDevice c заданными параметрами (листинг 1.1) . Листинг 1.1 private void MainForm_Load(object sender, EventArgs e) { // Инициализируем все поля структуры presentParams значениями по умолчанию PresentationParameters presentParams = new PresentationParameters(); // Мы будем осуществлять вывод на поверхность формы, то есть в оконном режиме presentParams.IsFullScreen = false; // Включаем двойную буферизацию presentParams.BackBufferCount = 1; // Переключение буферов должно осуществляться с максимальной эффективностью presentParams.SwapEffect = SwapEffect.Discard; // Задаѐм ширину и высоту клиентской области окна. Если присвоить этим полям значение 0 (что // и происходит по умолчанию), то конструктор класса GraphivsDevice автоматически рассчитает // значение этих полей и занесѐт их в структуру presentParams. Поэтому эти две строки, в // принципе, можно и опустить. presentParams.BackBufferWidth = ClientSize.Width; presentParams.BackBufferHeight = ClientSize.Height; // // // // // // //
Создаѐм новое устройство, обладающее следующими характеристиками: - Устройство будет использовать видеоадаптер по умолчанию - Устройство будет аппаратным - Вывод будет осуществляться на поверхность текущей формы – Обработка вершин будет осуществляться средствами GPU - Представление данных на экране задаѐтся структурой presentParams (см. выше) – Доступ к устройству возможен только из одного потока приложения device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, Handle, CreateOptions.SoftwareVertexProcessing | CreateOptions.SingleThreaded, presentParams); }
Создав объект устройства, можно приступать к реализации закраски формы синим цветом. Для этого воспользуемся методом Clear13 класса GraphicsDevice, который очищает форму путѐм закраски еѐ заданным цветом: public void Clear(ClearOptions options, Color color, float depth, int stencil);
где 12
Во всех примерах книги главная форма приложения называется MainForm. Практически все классы XNA Framework, включая GraphicsDevice, имеют множество перегрузок (override) конструкторов и методов (иногда более десятка) “на все случаи жизни”. В этой в этой книге будут рассматриваться лишь наиболее распространѐнные и общие из них – информацию об остальных перегруженных методах вы легко сможете найти в справочной системе. 13
options – набор битовых флагов, указывающих какие буферы необходимо очистить. Для очистки экранного буфера используется флаг ClearOptions.Target. Остальные флаги ClearOptions.DepthBuffer и ClearOptions.Stencil, используемые для очистки соответственно
буфера глубины и буфера шаблона, которые будут рассмотрены в следующих главах.
color – цвет, которым будет закрашен экран. Задаѐтся с использованием структуры Microsoft.Xna.Framework.Graphics.Color, являющейся функциональным аналогом структуры System.Drawing.Color. Появление такого брата-близнеца обусловлено необходимостью сделать XNA
Framework независимым от функциональности платформы Windows.
depth – значение, которым будет “закрашен” буфер глубины.
stencil – значение, которым будет заполнен буфер шаблона.
Вызов метода Clear необходимо вставить в обработчик события Paint, вызываемый каждый раз при необходимости перерисовки содержимого формы: private void MainForm _Paint(object sender, PaintEventArgs e) { device.Clear(ClearOptions.Target, Microsoft.Xna.Framework.Graphics.Color.CornflowerBlue, 0.0f, 0); }
Обратите
внимание
на
использование
полного
имени
структуры
Microsoft.Xna.Framework.Graphics.Color с указанием пространства имен. Если это не сделать, возникнет конфликт с одноименной структурой из пространства имен System.Drawing.
Класс GraphicsDevice имеет и более простую перегрузку (override) метода, предназначенную для очистки исключительно экранного буфера: public void Clear(Color color);
где
color – цвет, которым заполняется весь экран.
Использование данного варианта перегрузки метода позволяет несколько упростить код приложения: private void MainForm_Paint(object sender, PaintEventArgs e) { device.Clear(Microsoft.Xna.Framework.Graphics.Color.CornflowerBlue); }
По окончанию работы приложение должно удалить графическое устройство при помощи метода Dispose. Для этой цели идеально подходит обработчик события FormClosed (листинг 1.2). Листинг 1.2. private void MainForm_FormClosed(object sender, FormClosedEventArgs e) { // Если устройство существует if (device != null) { // Удаляем (освобождаем) устройство device.Dispose(); // На всякий случай присваиваем ссылке на устройство значение null device = null; } }
Если вы забудете удалить объект устройства, .NET самостоятельно попытается вызвать метод Dispose экземпляра класса GraphicsDevice в процессе сборки мусора (если быть более точным, сборщик мусора вызывает метод Finalize, который довольно часто реализует вызов метода Dispose). Но здесь имеется один нюанс. Как известно, сборщик мусора для вызова методов Finalize удаляемых объектов создаѐт отдельный поток, в то время как на платформе Windows устройство Direct3D имеет право удалить только поток, создавший это устройство. Соответственно, деструктор объекта GraphicsDevice, вызываемый из параллельного потока, не сможет корректно удалить устройство Direct3D. П р им еч а н ие. Даже если вы не укажите при создании устройства флаг CreateOptions.SingleThreaded, сборщик мусора всѐ равно не сможет корректно удалить объект.
Вроде бы всѐ. Давайте попробуем запустить полученное приложение на выполнение (клавиша F5). Не смотря на то, что метод Clear вызывается при каждой перерисовке окна (в этом легко убедится, установив точку останова на строку с вызовом этого метода при помощи клавиши F9) , на внешнем виде формы это никак не отражается. Интересно, с чем это может связано? Всѐ дело в том, что мы используем двойную буферизацию, то есть наше приложение выполняет все графические построения в невидимом вспомогательном буфере. После окончания визуализации необходимо скопировать информацию из этого вспомогательного буфера на форму. Эту операцию выполняет метод Present класса GraphicsDevice: void Present()
Добавьте вызов этого метода в конец обработчика события Paint и снова запустите программу на выполнение – на экране появится окно, закрашенное синим цветом, что и требовалось. Исходный код готового приложения находится на CD с книгой в каталоге Ch01\Ex01.
1.2. Визуализация шахматной доски. Одна из перегрузок метода GraphicsDevice.Clear позволяет очищать не весь экран целиком, а лишь заданную прямоугольную область формы: public void Clear(ClearOptions Rectangle[] regions);
options,
Color
color,
float
depth,
int
stencil,
где rect – массив структур Microsoft.Xna.Framework.Rectangle, задающих прямоугольные области
экрана, которые должны быть очищены. Области экрана задаются в оконных координатах формы – начало координат расположено в левом верхнем углу. Структура Microsoft.Xna.Framework.Rectangle является близнецом одноименной структуры из пространства имен System.Drawing, и используется во избежание привязки XNA Framework к платформе Windows. П р им еч а н ие Структура Rectangle объявлена в пространстве имен Microsoft.Xna.Framework, так как он
используется многими классами XNA Framework, в том числе и не из пространства имен Microsoft.Xna.Framework.Graphics. К примеру, следующий фрагмент кода нарисует в центре экрана зелѐный прямоугольник на синем фоне (рисунок 1.6): Листинг 1.3. // Закрашиваем экран синим цветом device.Clear(Microsoft.Xna.Framework.Graphics.Color.CornflowerBlue); // Создаѐм массив с координатами областей экрана, которые необходимо закрасить. Нам // нужна всего одна область Microsoft.Xna.Framework.Rectangle[] rect = new Microsoft.Xna.Framework.Rectangle[1]; // Задаѐм координаты области экрана, расположенной в центре экрана и занимающей 25% // площади экрана rect[0] = new Microsoft.Xna.Framework.Rectangle(ClientSize.Width/4, ClientSize.Height/4, ClientSize.Width/2, ClientSize.Height/2); // Закрашиваем эту область зелѐным цветом device.Clear(ClearOptions.Target, Microsoft.Xna.Framework.Graphics.Color.Green, 0.0f, 0, rect);
Рисунок 1.6. Зелѐный квадрат на синем фоне, нарисованный с использованием метода Clear.
В принципе при грамотном использовании только одного метода Clear можно получать довольно интересные изображения. К примеру, никто не мешает нам нарисовать шахматную доску (рисунок 1.7). Для этого необходимо очистить экран белым цветом, затем создать массив областей экрана, соответствующих клеткам доски коричневого цвета и ещѐ раз очистить экран, но уже коричневым цветом (листинг 1.4).
Рисунок 1.7. Шахматная доска, нарисованная с использованием метода Clear
Листинг 1.4. // Полный код приложения находится в каталоге Examples\Ch01\Ex02 private void MainForm_Paint(object sender, PaintEventArgs e) { // Очищаем экран белым цветом device.Clear(Microsoft.Xna.Framework.Graphics.Color.WhiteSmoke); // Создаѐм массив областей закраски, соответствующих коричневым клеткам Microsoft.Xna.Framework.Rectangle[] rects = new Microsoft.Xna.Framework.Rectangle[32]; int k = 0; // Перебираем коричневые клетки шахматной доски for (int j = 0; j < 8; j++) for (int i = j % 2; i < 8; i += 2) { // Заносим в массив координаты очередной клетки rects[k] = new Microsoft.Xna.Framework.Rectangle(i * ClientSize.Width / 8, j * ClientSize.Height / 8, ClientSize.Width / 8, ClientSize.Height / 8); k++;
} // Закрашиваем все области из массива rects коричневым цветом device.Clear(ClearOptions.Target, Microsoft.Xna.Framework.Graphics.Color.Brown, 0.0f, 0, rects); device.Present(); }
1.2.1. Конфигурирование DirectX для отладки приложения. Как вы помните, на платформе Windows XNA Framework в некотором роде является высокоуровневой надстройкой над DirectX. Соответственно, на платформе Windows подавляющее большинство вызовов методов XNA Framework так или иначе транслируется в вызовы методов DirectX. В большинстве случаев это обстоятельство можно полностью игнорировать. Тем не менее, при возникновении различных “аномалий” в приложении обращение к нижележащему уровню может помочь быстро решить проблему. Конфигурирование DirectX осуществляется при помощи утилиты DirectX, запускаемой командой Start | All Programs | Microsoft DirectX SDK | DirectX Utilities | DirectX Control Panel. Внешний вид этой утилиты изображѐн на рисунке 1.8. Как видно, данная утилита представляет собой обычное диалоговое окно с набором вкладок, отвечающих за настройку различных компонентов DirectX. Рассмотрим наиболее важные из них.
Рисунок 1.8. Внешний вид утилиты конфигурирования DirectX. Открыта вкладка Direct3D.
Вкладка Direct3D предназначена для настройки компонента Direct3D Graphics. Как правило, эта вкладка используется для переключения между отладочной и “обычной” версией Direct3D при помощи переключателей Use Debug Version of Direct3D и Use Retail Version of Direct3D соответственно (расположены в группе Debug/Retail D3D Runtime). Отладочная версия Direct3D Graphics проводит дополнительную проверку правильности
параметров передаваемых классам Direct3D Graphics и правильности выполнения этих методов. Информация об различных подозрительных ситуациях и ошибках передаѐтся в отладчик (например, в отладчик Visual Studio 2005). При разработке и отладке приложений рекомендуется всегда использовать отладочную версию Direct3D Graphics. Так же полезно установить ползунок Debug Output Level, отвечающий за подробность отладочной информации в крайнее правое положение, чтобы получать информацию о любых подозрительных ситуациях. Ведь согласно “эффекту бабочки”, даже самый безобидный на первый взгляд недочѐт может привести к каскаду трудноуловимых ошибок. В группе Debugging желательно включить следующие флажки: Maximum Validation (максимальная проверка корректности параметров, передаваемых классам Direct3D Graphics), Enable Shader Debugging (отладка шейдеров) и Break on Memory Leaks (обнаружение утечек памяти). В ни ма н ие. Отладочная (Debug) версия DirectX Graphics значительно медленнее обычной (Retail) версии. Поэтому не забывайте отключать отладочную версию DirectX по завершению отладки. В противном случае вы рискуете столкнуться с аномально низкой производительностью трѐхмерных игр и аналогичных приложений. Для того чтобы переключиться в нормальный режим, достаточно просто включить радиокнопку Use Retail Version of DirectX – остальные опции вроде Debug Output Level не оказывают никакого влияния на обычную версию Direct3D Graphics.
Debug View По умолчанию Visual Studio 2005 Pro не отображает сообщения от отладочной версии DirectX, а в бесплатной версии Visual C# 2005 Express подобная функциональность не предусмотрена в принципе. Поэтому я включил на CD диск с книгой бесплатную программу Марка Руссиновича Debug View, расположенную в каталоге \Tools\DebugView. Скопируйте еѐ на локальный жесткий диск компьютера и запустите файл DebugView.exe. На экране появится окно следующего вида (рисунок 1.9). Наибольший интерес для нас представляет центральная часть окна, в которой отображают отладочные сообщения от всех приложений, выполняющихся в данный момент на компьютере. Если вы поработаете некоторое время на компьютере при запущенной утилите Debug View, то наверняка заметите множество отладочных сообщений от разнообразных приложений.
Рисунок 1.9. Приложение Debug View
Откройте панель управления DirectX, и включите отладочную версию DirectX. Запустите на выполнение приложение, рисующую шахматную доску (пример Ch01\Ex02), поработайте с ним некоторое время, после чего завершите. Тем временем в окне Debug Info появится информация следующего вида: // Библиотека Direct3D загружается в адресное пространство нашего приложения Direct3D9: :====> ENTER: DLLMAIN(041dd6e0): Process Attach: 0000041c, tid=000016a8 Direct3D9: :====> EXIT: DLLMAIN(041dd6e0): Process Attach: 0000041c // Direct3D находится в отладочном режиме Direct3D9: (INFO) :Direct3D9 Debug Runtime selected.
// Расширенные возможности отладки Direct3D недоступны (эта функциональность доступна // только для DirectX-приложений, написанных на C++) D3D9 Helper: Enhanced D3D_DEBUG_INFO
D3DDebugging
disabled;
Application
was
not
compiled
with
// Сообщение с пометкой INFO содержат разнообразную служебную информацию о ходе // выполнения приложения. В частности следующее сообщение означает, что устройство // находится в режиме Software Vertex Processing (Программная обработка вершин). Иными // словами, при создании графического устройства был использован флаг // CreateOptions.SoftwareVertexProcessing. Direct3D9: (INFO) :======================= Hal SWVP device selected Direct3D9: (INFO) :HalDevice Driver Style 9 Direct3D9: :DoneExclusiveMode Direct3D9: :====> ENTER: DLLMAIN(041dd6e0): Process Detach 0000041c, tid=0000022c // Освобождение ресурсов Direct3D завершено Direct3D9: (INFO) :MemFini! // Завершение работы Direct3D Direct3D9: :====> EXIT: DLLMAIN(041dd6e0): Process Detach 0000041c
Обр а т ит е вн има н ие В столбце Time указано время поступления отладочного сообщения, что облегчает идентификацию сообщений. По умолчанию используется относительное время – за точку отсчѐта берется время поступления первого события, т.е. время наступление первого события всегда равно 0.0 секунд.
Как видно, приложение выполняется без каких-либо эксцессов. Теперь закомментируйте строку device.Dispose() в обработчике события Close() и снова запустите приложение на выполнение. На этот раз отладочные сообщения будут несколько отличаться: Direct3D9: :====> ENTER: DLLMAIN(042dd6e0): Process Attach: 00000298, tid=00000910 Direct3D9: :====> EXIT: DLLMAIN(042dd6e0): Process Attach: 00000298 Direct3D9: (INFO) :Direct3D9 Debug Runtime selected. D3D9 Helper: Enhanced D3D_DEBUG_INFO
D3DDebugging
disabled;
Application
was
not
compiled
with
Direct3D9: (INFO) :======================= Hal SWVP device selected Direct3D9: (INFO) :HalDevice Driver Style 9 Direct3D9: :DoneExclusiveMode // Предупреждение. К устройству, не рассчитанному на работу в многопоточном режиме (в // конструкторе класса GraphicsDevice указан флаг CreateOptions.SingleThreaded) // пытается обратиться другой поток. Direct3D9: (WARN) :Device that was created without D3DCREATE_MULTITHREADED is being used by a thread other than the creation thread. // Ошибка! Устройство может быть уничтожено только потоком, создавшим его Direct3D9: (ERROR) :Final Release for a device can only be called from the thread that the device was created from. Direct3D9: (WARN) :Device that was created without D3DCREATE_MULTITHREADED is being used by a thread other than the creation thread.
Direct3D9: (WARN) :Device that was created without D3DCREATE_MULTITHREADED is being used by a thread other than the creation thread. Direct3D9: (WARN) :Device that was created without D3DCREATE_MULTITHREADED is being used by a thread other than the creation thread. Direct3D9: (WARN) :Device that was created without D3DCREATE_MULTITHREADED is being used by a thread other than the creation thread. Direct3D9: :====> ENTER: DLLMAIN(042dd6e0): Process Detach 00000298, tid=00000520 Direct3D9: (INFO) :MemFini! Direct3D9: :====> EXIT: DLLMAIN(042dd6e0): Process Detach 00000298
Хотя приложение и работает нормально, по отладочным сообщениям можно легко догадаться, что при завершении работы приложения сборщик мусора попытался уничтожить устройство, что естественно не удалось – ведь сборщик мусора вызывает методы Finalize в отдельном потоке, в то время как устройство Direct3D может удалить лишь тот поток, который его создал. Таким образом, отладочная версия DirectX помогла нам легко локализовать проблему. Единственное неудобство доставляют отличия между Direct3D и XNA Framework. К примеру, устройство Direct3D по умолчанию запускается в однопоточном режиме, а включение поддержки многопоточного режима осуществляется путем указания флага D3DCREATE_MULTITHREADED. А вот класс GraphicsDevice, напротив, по умолчанию создаѐт графическое устройство с поддержкой многопоточности, а отключение данной функциональности осуществляется путем указания флага CreateOptions.SingleThreaded. Кроме того, отладочная версия Direct3D нечего не знает о .NET Framework – вместо того, чтобы сообщить о проблемах из-за удаления объекта GraphicsDevice сборщиком мусора она просто жалуется на странное поведение непонятно откуда взявшегося дополнительного потока. Впрочем, получив некоторый опыт чтения сообщений отладочной версии Direct3D, вы перестанете обращать внимание на подобные нюансы. Взаимодействие XNA Framework c DirectX. Как говорилось выше, XNA Framework по сути является прослойкой между .NET и DirectX. Но насколько эта тонка прослойка и оказывает ли она существенное влияние на производительность приложения? Чтобы ответить на этот вопрос мы рассмотрим работу XNA Framework на примере метода Direct3D.Device.Present, декомпилировав его с использованием .NET Reflector, который находится на CD с книгой в каталоге Tools\NET Reflector14: public unsafe void Present() { // Проверяет, не был ли вызван метод Dispose для данного экземпляра класса // GraphicsDevice. Если метод Dispose уже был вызван, генерируется исключение // ObjectDisposedException InternalHelper.CheckDisposed(this, (void*) this.pComPtr); // Получает указатель на COM–интерфейс IDirect3DDevice9, являющийся низкоуровневым // аналогом класса GraphicsDevice. IDirect3DDevice9* devicePtr1 = this.pComPtr; // Вызывает COM-метод IDirect3DDevice9::Present, который выполняет переключение // буферов и вывод изображения на экран. К сожалению, .NET Reflector сгенерировал, // мягко говоря, не самый красивый код. int num1 = **(((int*) devicePtr1))[0x44](devicePtr1, 0, 0, 0, 0); // Если метод IDirect3DDevice9::Present возвратил отрицательное значение, то есть во // время выполнения метода произошла ошибка (в COM отрицательные значения // соответствуют кодам ошибок). if (num1 < 0) { // Если код ошибки равен -2005530520 (соответствует потери устройства) if (num1 == -2005530520) { // Подготовка к вызову обработчика события DeviceLost (если он определен) с // последующим вызовом. Тема потери устройства будет рассмотрена в разделе 1.2.4. EventArgs args1 = EventArgs.Empty; 14
Конечно, C#-код, генерируемый .NET Reflector, далѐк от идеала. Тем не менее, его код несоизмеримо проще анализировать по сравнению с ассемблерным IL-кодом.
EventHandler handler1 = this.DeviceLost; if (handler1 != null) { handler1(this, args1); } } // Генерируется исключение. Класс исключения и текст сообщения об ошибке определяется // кодом, который вернул Com-метод IDirect3DDevice9::Present throw ExceptionHelper.GetExceptionFromResult(num1); } }
Как видно, метод GraphicsDevice.Present содержит вызов COM-метода IDirect3DDevice9::Present плюс небольшую обвязку для взаимодействия с COM. Иными словами, на платформе Windows метод GraphicsDevice.Present по сути является обвязкой над методом . Впрочем, на других платформах всѐ может быть совершенно иначе.
1.2.2. Конфигурирование проектов в Visual Studio 2005 В этом разделе мы рассмотрим тонкости настройки свойств проекта в среде Visual Studio 2005 с учѐтом специфики приложений, использующих XNA Framework. Если вы имеете большой опыт работы с Visual Studio, можете пропустить этот раздел. Как вы знаете, самой крупной единицей Visual Studio 2005 является решение (Solution), описание которого хранятся в текстовом файле формата XML с расширением .sln. Каждое решение состоит из одного или нескольких проектов: набора файлов исходного кода и ресурсов, которые будут откомпилированы в одну сборку (исполняемый файл .exe или динамическую библиотеку .dll). Файлы с описанием проектов, использующих язык C#, имеют расширение .csproj. Файлы исходного кода C#-программы имеют расширение .cs, файлов ресурсов – .resx и т.д. В принципе для получения представления о структуре решения достаточно открыть любой проект и взглянуть на вкладку Solution Explorer (рисунок 1.4). П р им еч а н ие Возможность хранить несколько проектов внутри одного решения очень полезна при разработке сложных приложений. К примеру, вы можете держать в одном решении 4 проекта: библиотека моделирования искусственного интеллекта, редактор уровней, собственно игра и еѐ инсталлятор. Эта возможность будет довольно активно использоваться в ряде примеров книги.
Проект может иметь несколько конфигураций, позволяющих быстро переключаться между различными настройками проекта. При создании нового проекта Visual Studio 2005 добавляет в него две конфигурации: Debug и Release. Конфигурация Debug предназначена для отладки приложения. При использовании этой конфигурации приложение компилируется без использования оптимизаций, а в .exe файл добавляются отладочные символы. Благодаря этому отладчик может найти однозначное соответствие между полученным двоичным ком и исходным текстом программы, что позволяет осуществлять пошаговое выполнение программы, просмотр промежуточных значений переменных и т.п. Кроме того, программист, используя директивы условной компиляции, может добавлять в отладочную версию код ряд разнообразных дополнительных расширенных проверок вводимых данных. Конфигурация Release применяется для построения финальной версии приложения. В этом случае компилятор применяет различные оптимизации: удаление лишних переменных, перестановка инструкций, разворачивание циклов и т.п. Это значительно повышает производительность приложения (иногда в десятки раз), однако значительно усложняет отладку, которая возможна лишь на уровне машинного кода. Переключение между этими конфигурациями легко осуществляется с использованием выпадающего списка на панели инструментов Visual Studio (рисунок 1.10). Вообще, конфигурации Debug и Release отличаются между собой лишь настройками различных свойств. Теоретически, проигравшись со свойствами конфигурации Debug, вы можете легко превратить еѐ в функциональный аналог конфигурации Release и наоборот. Кроме того, вы можете добавить в решение некоторые специфические конфигурации вроде “Release Shareware Version”, “Release Full Version” и т.д.
Рисунок 1.10. Переключение между различными конфигурациями с использованием панели инструментов
П р им еч а н ие Так как конфигурации Debug и Release генерируют очень сильно различающийся код, иногда возникают ситуации, когда ошибка в программе проявляется только Debug или Release-коде. Поэтому я настоятельно рекомендую вам тестировать промежуточные версии вашей программы в обоих режимах.
Конфигурация Release в проектах C# по умолчанию настроена довольно оптимально и вам вряд ли придѐтся еѐ менять. К сожалению, этого нельзя сказать о конфигурации Debug. Сейчас мы с вами это исправим. Активируйте конфигурацию Debug при помощи выпадающего списка на панели задач. Щелкните два раза на узле Properties во вкладке Solution, чтобы открыть вкладку свойств проекта 15. (рисунок 1.11).
Рисунок 1.11. Свойства проекта
Для начала щѐлкните на закладку Debug и включите флажок Enable unmanaged code debugging. Из названия нетрудно догадаться, что этот флажок включает отладку неуправляемого кода. Зачем это надо? Большинство классов сборки XNA Framework являются тонкими надстройками над COM-компонентами “обычного” DirectX16. В результате при выключенной отладке неуправляемого кода Visual Studio 2005 не может получать информацию от COM-компонентов отладочной версии DirectX и, соответственно, выводить еѐ в окно Output (см. раздел 1.1).
15 16
Эта вкладка, по сути, является визуальным редактором файла проекта (*.cjproj и т.п.) Данное утверждение может быть не верным для платформ, отличных от Windows.
Попробуйте включить эту опцию в примере Ch01\Ex02 (визуализация шахматной доски) и понаблюдать в окне Output за сообщениями отладочной версии DirectX (рисунок 1.12). Отметьте ощутимо возросшее время загрузки приложения.
Рисунок 1.12. Окно Output.
В целом, окно Output в сочетании с опцией Enable unmanaged code Debugging является неплохой интегрированной альтернативой утилите Debug View (см. раздел 1.1.1), хотя и не лишенной ряда недостатков – очень низкой производительности и отсутствия поддержки в Visual C# 2005 Express. В ни ма н ие Даже если вы выбрали конфигурацию Release, Visual Studio при нажатии клавиши F5 (Start with Debugging) всѐ равно запускает .NET-приложение под управлением отладчика, что ощутимо снижает быстродействия. Для полного отключения отладки необходимо запустить приложение на выполнение комбинацией клавиш Ctrl + F5 (Start without debugging).
Проверка переполнения Вторая полезная опция, Check for arithmetic overflow/underflow, находится в диалоговом окне Advanced Build Setting, открываемом при помощи кнопки Advanced Build Setting в закладке Build (рисунок 1.13). Этот флажок включает проверку целочисленного переполнения для всей программы: если в ходе вычислений результат вдруг выйдет за пределы допустимого диапазона, то программа автоматически сгенерирует исключение System.OverflowException. Эта поможет сэкономить вам много времени и нервов, при поиске трудноуловимых ошибок вроде: // Объявляем 16-битную переменную со знаком и присваиваем ей 32767 short a = 32767; // Увеличиваем еѐ на 5. Из за переполнения переменная a станет равна -32764, а не 32752 !!! a = (short) (a + 5); // В итоге оператор WriteLine выведет на экран -32764 Console.WriteLine(a);
Рисунок 1.13. Диалоговое окно Advanced Build Setting
Так как проверка переполнения несколько снижает производительность программы, еѐ рекомендуется выключать в конфигурации Release. Если же у вас имеется потенциально опасный код, в котором может произойти переполнение, поместите его во внутрь блока checked. Например:
checked { a = (short) (a + 5); }
Остальные особенности конфигурирования проектов C# мы рассмотрим в следующих разделах по мере необходимости. Более подробную информацию по этой теме можно найти в [К.7], [К.8] и [К.9].
1.2.3. Изменение размеров окна Запустите на выполнение нашу программу, рисующую шахматную доску (Ex02) и попробуйте поизменять размеры окна. Думаю, вы быстро заметите, что с программой что-то не так. При уменьшении размеров окна шахматная доска не масштабируется, в результате чего изображение начинает выглядеть как-то странновато (рисунок 1.13). А при увеличении окна изображение шахматной доски искажается непонятным образом (рисунок 1.14). Как гласит народная мудрость, за двумя зайцами погонишься – ни одного не поймаешь. Поэтому для начала мы сосредоточимся на первой проблеме – неизменном размере шахматной доски при уменьшении формы. Эта проблема вызвана тем, что по умолчанию Windows Forms не гарантирует вызов события Paint при изменении размеров формы. В принципе, для борьбы с этим недоразумением мы могли бы добавить в обработчик события Resize17 вызов метода Invalidate, генерирующего событие Paint.
Рисунок 1.13. Некорректная реакция программы на уменьшение размера окна.
Рисунок 1.14. Некорректная реакция программы на увеличение размера окна
Однако существует гораздо более элегантное решение: если установить у формы стиль ResizeRedraw, то при изменении размера формы будет автоматически генерироваться событие Paint. Для этого добавьте в обработчик Load строку: SetStyle(ControlStyles.ResizeRedraw, true);
Попробуйте ещѐ раз запустить программу на выполнение. И что мы видим? Хотя теперь приложение и реагирует на изменение размера окна, появились новая, гораздо более неприятная проблема – при изменении размеров окна форма непрерывно мерцает. Чтобы понять причину этого явления попробуйте изменить цвет формы на зелѐный (свойство BackColor) и снова запустите на выполнение. Мерцания шахматной доски обретут зеленоватый оттенок.
17
Событие Resize генерируется при изменении размеров формы
И так всѐ дело в том, что перед вызовом обработчика события Paint класс Form вызывает виртуальный метод OnPaintBackground, который по умолчанию очищает экран цветом BackColor. Эта функциональность позволяет разработчику, использующему GDI+, не заботится об очистке экрана, однако в нашем случае такая “самовольная” очистка формы приводит лишь к мерцанию. Следовательно, нам необходимо каким-нибудь образом запретить форме закрашивать экран перед вызовом обработчика события Paint. Первое, что приходит в голову – перегрузить метод OnPaintBackground protected override void OnPaintBackground(PaintEventArgs pevent) { // Ничего не делаем }
Впрочем, если покопаться в документации, можно найти и более изящное решение проблемы. Если установить у формы стиль ControlStyles.Opaque, то форма не будет автоматически закрашивать фон, что собственно нам и нужно: SetStyle(ControlStyles.Opaque, true);
После добавления этой строки в обработчик события Load мерцания наконец-то исчезнут. И так, первую проблему мы решили, но осталась вторая, гораздо более неприятная – некорректное масштабирование шахматной доски при изменении размера окна. На первый взгляд проблема возникает буквально на пустом месте – мы задаѐм параметры двойной буферизации и вспомогательных буферов, создаѐм новое устройство, после чего перерисовываем экран в обработчике события Paint. Вроде бы ничего противозаконного мы не делаем… Стоп! При создании графического устройства мы задаѐм размер вспомогательного буфера, используемого при двойной буферизации, равный размеру клиентской области окна, что вполне логично. Когда мы изменяем размер окна, его клиентская область так же изменяется. А вот размер вспомогательного буфера остаѐтся неизменным – мы ведь задаѐм его размер только один раз в обработчике события Load. Получается что, при изменении размеров окна происходит рассинхронизация между размером клиентской области окна и вспомогательного буфера, в котором собственно рисуется изображение, после чего приложение естественно начинает работать некорректно. Следовательно, нам необходимо добавить в программу коррекцию размера вспомогательного буфера при изменении размера окна. В XNA Framework эта операция выполняется с использованием метода Reset класса GraphicsDevice: public void Reset(params PresentationParameters[] presentationParameters);
где
presentationParameters
представление
данных
– на
набор структур PresentationParameters, описывающих новое экране. Каждому монитору соответствует своя структура
PresentationParameters.
И так, нам придѐтся вынести локальную переменную PresentationParameters presentParams за пределы метода Load (то есть сделать еѐ полем класса MainForm) и добавить в обработчик события Resize изменение высоты и ширины вспомогательного буфера структуры presentParams с последующим вызовом метода Device.Reset (листинг 1.5). Листинг 1.5. private void MainForm_Resize(object sender, EventArgs e) { // Задаѐм новые размеры буфера глубины presentParams.BackBufferWidth = ClientSize.Width; presentParams.BackBufferHeight = ClientSize.Height; // Применяем новые параметры к устройству device.Reset(presentParams); }
П р им еч а н ие Сброс устройства является очень медленной операция. Никогда не вставляйте еѐ без причины в обработчик события Paint, так как это приведѐт к заметному падению производительности.
После такой модернизации наша программа наконец-то заработала без ошибок. Хотя так ли это? Как гласит известная пословица, в каждой программе есть как минимум одна ошибка. Наша программа не исключение. Попробуйте из интереса уменьшить еѐ размер до минимума. Как только высота клиентской области окна станет меньше одного пикселя, в окне Output появятся сообщения от отладочной версии DirectX, а программа аварийно завершится с исключением:
Direct3D9: (ERROR) :Failed to create driver surface Direct3D9: (ERROR) :Reset failed and Reset/TestCooperativeLevel/Release are the only legal APIs to be called subsequently A first chance exception of type 'Microsoft.DirectX.Direct3D.DriverInternalErrorException' occurred in Microsoft.DirectX.Direct3D.dll
Поведение Direct3D вполне логично, ведь попытка вывести изображение на форму с клиентской областью размером менее одного пикселя выглядит, мягко говоря, довольно странной. Однако пользователя такое оправдание вряд ли обрадует, поэтому неплохо бы обезопаситься от подобных казусов, ограничив минимальный размер клиентской области одним пикселем. Это легко можно сделать при помощи свойства MinimumSize, которое задаѐт минимальные размеры окна. Правда задание этого свойства во вкладке Properties не самая лучшая идея – область, отводимая формой под клиентскую область, зависит от множества факторов: установленной операционной системы, пользовательских настроек и т.п. Гораздо надѐжнее вычислять его прямо в обработчике события Load посредством метода формы SizeFromClientSize, который возвращает размер окна при заданном размере клиентской области: // Вычисляем размер окна при клиентской области 1x1 пиксель. Полученное значение присваиваем // свойству MinimumSize MinimumSize = SizeFromClientSize(new Size(1, 1));
Ещѐ одной ошибкой стало меньше. Думаю, вы уже убедились, что написать приложение без единой ошибки для такой сложной операционной системы, как Windows,не так уж и просто. Всегда существует вероятность пропустить какой-нибудь нюанс. Например, мы до сих пор не пробовали минимизировать окно при помощи соответствующей стандартной кнопки в правом верхнем углу окна. Попробуйте нажать эту кнопку, и в обработчике события Resize тут же произойдѐт исключение – приложение попытается установить размер заднего буфера равным 0 на 0 пикселей. Такое поведение программы обусловлено тем, что при минимизации окна программы Windows уменьшает его размер до нуля пикселей. Следовательно, нам необходимо вставить в обработчик события Resize проверку состояния окна – если окно минимизировано, то программа не должна пытаться изменять размер заднего буфера: Листинг 1.6. private void MainForm_Resize(object sender, EventArgs e) { // Если окно не минимизировано, то изменяем размер заднего буфера и сбрасываем устройство if (WindowState != FormWindowState.Minimized) { presentParams.BackBufferWidth = ClientSize.Width; presentParams.BackBufferHeight = ClientSize.Height; device.Reset(presentParams); } }
Вот теперь наша программа похоже уже не содержит явных ошибок 18. Полный текст полученного приложения приведѐн в листинге 1.7 (Ex03). Листинг 1.7. using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Text; using System.Windows.Forms; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Xna = Microsoft.Xna.Framework; using XnaGraphics = Microsoft.Xna.Framework.Graphics; 18
Некоторые ошибки всѐ же остались, и мы в этом убедимся в следующем разделе (1.2.4).
namespace GSP.XNA.Book.Ch01.Ex03 { public partial class MainForm : Form { GraphicsDevice device=null; PresentationParameters presentParams; public MainForm() { InitializeComponent(); } private void MainForm_Load(object sender, EventArgs e) { SetStyle(ControlStyles.Opaque | ControlStyles.ResizeRedraw, true); MinimumSize = SizeFromClientSize(new Size(1, 1)); presentParams = new PresentationParameters(); presentParams.IsFullScreen = false; presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; presentParams.BackBufferWidth = ClientSize.Width; presentParams.BackBufferHeight = ClientSize.Height; device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, CreateOptions.SoftwareVertexProcessing | CreateOptions.SingleThreaded, presentParams); } private void MainForm_Paint(object sender, PaintEventArgs e) { device.Clear(ClearOptions.Target, XnaGraphics.Color.WhiteSmoke, 0.0f, 0); Xna.Rectangle[] rects = new Xna.Rectangle[32]; int k = 0; for (int j = 0; j < 8; j++) for (int i = j % 2; i < 8; i += 2) { rects[k] = new Xna.Rectangle(i * ClientSize.Width / 8, j * ClientSize.Height / 8, ClientSize.Width / 8, ClientSize.Height / 8); k++; } device.Clear(ClearOptions.Target, XnaGraphics.Color.Brown, 0.0f, 0, rects); device.Present(); } private void MainForm_Resize(object sender, EventArgs e) {
if (WindowState != FormWindowState.Minimized) { presentParams.BackBufferWidth = ClientSize.Width; presentParams.BackBufferHeight = ClientSize.Height; device.Reset(presentParams); } } private void MainForm_FormClosed(object sender, FormClosedEventArgs e) { if (device != null) { device.Dispose(); device = null; } } } }
Обратите внимание на применение псевдонимов для пространств имен Microsoft.Xna.Framework и Microsoft.Xna.Framework.Graphics, позволившие упростить обращение к структурам Microsoft.Xna.Framework.Color и Microsoft.Xna.Framework.Graphics.Rectangle. Обычная директива using прошлых версий C# в подобных ситуация оказывалась бессильной из-за конфликта с одноименными структурами из пространства имен System.Drawing.
Практическое упражнение №1.1. Создайте приложение, рисующее на экране 10 вложенных разноцветных прямоугольников (рисунок 1.15). Самый крупный прямоугольник должен иметь синий цвет, а самый маленький зелѐный. Остальные прямоугольники имеют промежуточные цвета, образуя плавный переход от синего к зелѐному. Приложение должно корректно реагировать на изменение размера экрана. П р им еч а н ие Для вычисления промежуточных значений цвета воспользуйтесь конструктором public Color(byte r, byte g, byte b), создающим структуру Color на основе значений красного, зеленого и синих цветов. Значения цветов находятся в диапазоне 0…255 (0 – минимальная яркость, 255 – максимальная яркость).
Рисунок 1.14. Иллюстрация к практическому упражнению №1.1.
Если у вас возникнут трудности при выполнении этого упражнения, вы можете посмотреть исходный текст готового приложения, которое находится на CD с книгой в каталоге Ch01\Ex04.
1.2.4. Восстановление работоспособности программы после потери устройства. Операционная система Windows является многозадачной операционной системой, поэтому параллельно с вашим XNA–приложением могут выполняться десятки других приложений использующих графическую подсистему компьютера. При этом не исключены конфликтные ситуации. Например, какая-нибудь программа может неожиданно изменить разрешение экрана, что вызовет перераспределение видеопамяти, что в свою очередь приведѐт к частичной потере информации, хранящейся в видеопамяти. В результате с большой долей вероятности класс GraphicsDevice вашего приложения потеряет информацию в видеопамяти и не сможет продолжать работу. В XNA Framework и DirectX это явление называется потерей устройства (Device Lost). Для начала нам надо научиться воспроизводить потерю устройства, иначе мы не сможем проверить, как наша программа поведѐт себя в случае потери устройства. Я обнаружил, что потеря устройства всегда 19 происходит при переключении консольного приложения в полноэкранный режим при помощи клавиш ALT + Enter. Следовательно, для форсирования потери устройства вам необходимо: 1. Запустить приложение, использующее DirectX. 2. Запустить консольное приложение. Я предпочитаю пользовать FAR, но если у вас он не установлен, вполне подойдѐт и обычная командная строка (Start | All Programs | Accessories | Command Prompt) 3. Переключиться в полноэкранный режим и обратно в оконный (два раза нажать Alt + Enter). Если вы проделаете эти операции над примером Ex03 из предыдущего раздел, то он аварийно завершится из-за не перехваченного исключением Microsoft.Xna.Framework.Graphics.DeviceLostException, название которого ясно говорит о причине краха приложения. Для определения текущего состояния устройства в классе GraphicsDevice имеется свойство GraphicsDeviceStatus: public GraphicsDeviceStatus GraphicsDeviceStatus { get; }
Свойство возвращает перечислимый тип GraphicsDeviceStatus, который может принимать одно из следующих значений: Normal – устройство функционирует нормально NotReset – устройство потеряно, но может быть восстановлено методом Reset GraphicsDevice.
класса
Lost – устройство потеряно и пока не может быть восстановлено. Если устройство находится в состоянии NotReset, то его необходимо восстановить, вызвав метод GraphicsDevice.Reset, после чего приложение может продолжать работу, как ни в чѐм не бывало. Если же устройство находится в состояние Lost, то тут нечего не поделать – остаѐтся лишь выйти из обработчика события Paint и ждать лучших времѐн. Потеря устройства является очень коварной проблемой, которая она может произойти не только до вызова обработчика Paint, но и внутри него. В этом случае XNA Framework в зависимости от ситуации сгенерирует исключения Direct3D.DeviceNotResetException или Direct3D.DeviceLostException. Если сгенерировано исключение Direct3D.DeviceNotResetException, то приложение должно восстановить устройство методом GraphicsDevice.Reset() и снова перерисовать изображение путѐм вызова метода Form.Invalidate(), в противном случае просто выйти из обработчика события Paint. После всего вышесказанного мы сможем легко научить нашу программу корректно реагировать на потерю устройства. Исходный код обновлѐнного обработчика события Paint приведѐн в листинге 1.8. Листинг 1.8. // Полный текст приложения находится в каталоге Examples\Ch01\Ex05 private void MainForm_Paint(object sender, PaintEventArgs e) { try { // Если устройство нельзя восстановить, генерируем исключение DeviceLostException (мы его // перехватим в блоке catch) 19
Я не встречал ни одной видеокарты, на которой в этом случае не происходила бы потеря устройства.
if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.Lost) throw new DeviceLostException(); // Если устройство можно восстановить, восстанавливаем его if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.NotReset) device.Reset(); // Рисуем шахматную доску device.Clear(XnaGraphics.Color.WhiteSmoke); Xna.Rectangle[] rects = new Xna.Rectangle[32]; int k = 0; for (int j = 0; j < 8; j++) for (int i = j % 2; i < 8; i += 2) { rects[k] = new Xna.Rectangle(i * ClientSize.Width / 8, j * ClientSize.Height / 8, ClientSize.Width / 8, ClientSize.Height / 8); k++; } device.Clear(ClearOptions.Target, XnaGraphics.Color.Brown, 0.0f, 0, rects);
// //
// //
device.Present(); } Если произошло исключение DeviceNotResetException, перерисовываем экран. Восстанавливать устройство не имеет смысла – наш обработчик события Paint сделает это автоматически catch (DeviceNotResetException) { Invalidate(); } Если произошло исключение DeviceLostException, то нам остаѐтся только ждать до лучших времѐн catch (DeviceLostException) { }
}
Практическое упражнение №1.2 “Обучите” программу, которую вы создали в упражнении №1.1, корректно реагировать на потерю устройства.
Заключение XNA Framework – это высокопроизводительная мультимедийная библиотека для разработки приложений, требовательных к производительности видеоподсистемы и аудиосистемы компьютера, а так же подсистеме ввода-вывода. Одной из причин появления XNA Framework являются некоторые трудности использования Microsoft DirectX на платформе .NET. В отличие от компонентов “обычного” DirectX, сборки Microsoft.Xna.Framework.dll и Microsoft.Xna.Framework.Game.dll соответствуют всем требованиям платформы .NET 2.0 и не привязаны к конкретному языку программирования. В результате эта сборка с одинаковой лѐгкостью могут использоваться в любой .NET-совместимой среде программирования вроде Microsoft Visual Basic, Microsoft Visual C# , Microsoft Visual J# или Iron Python. Кроме того, XNA Framework является платформо-независимый библиотекой, что открывает дорогу для переноса приложений на другие платформы, такие как, XBOX 360. Все компоненты XNA Framework условно можно разделить на четыре уровня абстракции: Platform, Core Framework, Extended Framework и Game. В этой главе мы познакомились с компонентом Graphics (один из компонентов уровня Core Framework), предназначенным для работы с графической подсистемой компьютера на относительно низком уровне. Плюсом подобного низкоуровневого подхода является высокая производительность приложения, минусом – необходимость учѐта разнообразных нюансов вроде коррекции размера вспомогательного буфера при изменении размера окна или и возможности неожиданной потери
устройства. Впрочем, даже поверхностные знания основных процессов, творящихся под капотом высокоуровневых графических библиотек, очень полезны при написании надежных высокопроизводительных графических приложений.
Глава 2. Визуализация примитивов. Процесс визуализации изображений в XNA Framework ощутимо отличаются от подхода, используемого в “классических” двухмерных библиотеках вроде GDI/GDI+. В XNA Framework все графические построения осуществляются с использованием простых фигур, называемых графическими примитивами. XNA Framework поддерживает три вида графических примитивов: точки, отрезки и треугольники. Эти примитивы очень просты в отображении, поэтому все современные графические ускорители могут рисовать их аппаратно с очень высокой скоростью. Например, видеокарта NVIDIA GeForce 8800 GTX может визуализировать порядка 400 миллионов треугольников в секунду 20 [С.7]. Каждый примитив задаѐтся набором вершин: точка – одной в центре точки, отрезок двумя вершинами на концах отрезка, а треугольник – тремя вершинами в углах треугольника. В XNA Framework координаты вершин обычно задаются тремя координатами x, y и z. Центр используемой системы координат расположен в центре клиентской области формы, ось положительное направление оси X направлено вправо, ось Y – вверх, а ось Z – из экрана монитора на наблюдателя (рисунок 2.1). Левый нижний угол формы имеет координаты (-1, -1, 0), верхний правый (+1, +1, 0). (-1, 1, 0)
(1, 1, 0) +Y
(0, 0, 0)
(-1, -1, 0)
+X
(1, -1, 0)
Рисунок 2.1. Система координат клиентской области формы при использовании XNA Framework.
2.1. Работа с вершинами примитивов. В пространстве имен Microsoft.Xna.Framework.Graphics имеется ряд структур для хранения информации о вершинах примитива. В настоявшее время для нас наиболее интересна структура VertexPositionColor, инкапсулирующая информацию о координатах и цвете вершины. Начнем рассмотрение этой структуры с конструктора: public VertexPositionColor(Vector3 position, Color color);
где
position – координаты вершины;
color – цвет вершины.
В процессе создания новой вершины ширина и высота автоматически заносятся конструктором в поля Position и Color структуры VertexPositionColor: public Color Color; public Vector3 Position;
Здесь мы впервые встречаемся с новой для нас структурой Microsoft.XNA.Framework.Vector3, инкапсулирующей трехмерный вектор. Наряду с Vector3 в XNA Framework определены структуры Vector2 и Vector4, которые, как нетрудно догадаться, предназначены для работы с двухмерными и 20
Приведена производительность в реальных задачах. Вообще производители обожают указывать в описании видеокарты теоретическую пиковую производительность, практически не достижимую в реальных приложениях. В целом, пиковая производительность видеокарты аналогична максимальной скорости автомобилей или локомотивов. К примеру, электропоезд TGV-A теоретически можно разогнать до 513 км/ч [15], однако на практике его средняя скорость не превышает 350 км/ч.
четырехмерными векторами. Структуры Vector2, Vector3, Vector4 широко используются в XNA Framework для хранения координат вершин, а так же выполнения различных математических векторных операций. Так как большая часть функциональности данных структур нам пока не нужна, мы отложим их подробное изучение до шестой главы. В конец концов, с точки зрения логики работы наших первых приложений структуры Vector2, Vector3 и Vector4 представляют собой всего лишь расширенную версию структуры System.Drawing.PointF. Информация обо всех вершинах примитива хранится в массиве. Например: // Вершин примитива VertexPositionColor[] vertices;
Казалось бы, всѐ должно быть очень просто, если бы не один нюанс. Дело в том, что при визуализации примитивов информация о вершинах напрямую передается в графический процессор видеокарты (GPU – Graphics Processor Unit), который не имеет ни малейшего понятия об управляемом коде и, соответственно, формате структуры. Для разъяснения графическому процессору формата отдельных полей структуры применяются декларации формата вершины. В XNA Framework декларация вершины инкапсулируется классом VertexDeclaration, конструктор которого приведен ниже: public VertexDeclaration(GraphicsDevice graphicsDevice, VertexElement[] elements);
где
graphicsDevice – графическое устройство, используемое для работы с вершинами
elements – массив элементов (структур VertexElement) c описанием формата структуры.
Описание формата структуры задается массивом elements, каждый элемент которого описывает одно поле структуры. Соответственно, количество элементов в массиве elements всегда равно количеству полей структуры. Какая информация содержится в каждом элементе массива elements? Это: 4. Адрес описываемого поля структуры (смещение от начала структуры). 5. Тип поля структуры (скаляр, вектор, упакованный вектор 21). 6. Информация, содержащаяся в данном поле (координаты вершины, цвет вершины, текстурные координаты22 и т.п.). 7. Некоторая другая служебная информация. Создание массива, описывающего структуру, является довольно монотонной и утомительной операцией. К счастью, разработчики XNA Framework встроили в структуру VertexPositionColor (а так же во все аналогичные структуры) статическое поле только для чтения, содержащее массив с описанием этой структуры: public static readonly VertexElement[] VertexElements;
Соответственно, для создания декларации вершины приложению достаточно лишь передать это поле в качестве второго параметра конструктора класса VertexDeclaration.
2.2. Основы визуализации примитивов. Рассмотрим основные этапы визуализации примитивов, информация о вершинах которых хранится в массиве структур VertexPositionColor. Сначала приложение должно создать декларацию вершины на основе описания, содержащегося в нашей структуре VertexTransformedPositionColor: VertexDeclaration decl; … decl = new VertexDeclaration(device, VertexTransformedPositionColor.vertexElements);
Эту операцию достаточно выполнять один при запуске приложения, например, где-нибудь в обработчике события Load формы. Код визуализации примитива следует поместить в обработчик события Paint. Перед тем, как приступить к визуализации примитивов, необходимо задать формат вершин примитива, присвоив свойству VertexDeclaration класса Device декларацию формата вершины, созданную в обработчике события Load. Собственно визуализация примитивов выполняется методом DrawUserPrimitives: DrawUserPrimitives(PrimitiveType primitiveCount); 21
primitiveType,
T[]
vertexData,
int
vertexOffset,
Примером упакованного вектора является 32-х битное число, содержащее информацию о яркости красного, синего и зеленого компонентов цвета. 22 Текстурные координаты будут рассмотрены в разделе 5.X.
int
где
primitiveType – тип примитива, задаваемый с использованием перечислимого типа PrimitiveType.
Различные типы примитивов будут подробно рассмотрены в разделах 1.2.1, 1.2.2 и 1.2.3. Пока же отметим, что в XNA Framework поддерживает шесть типов примитивов: список точек (PrimitiveType.PointList), список линий (PrimitiveType.LineList), полоса линий (PrimitiveType.LineStrip), список треугольников (PrimitiveType.TriangleList), полоса треугольников (PrimitiveType.TriangleStrip) и веер треугольников (PrimitiveType.TriangleFan).
vertexData – массив вершин примитива.
vertexOffset – смещение от начала массива. Данный параметр обычно равен нулю. Ненулевые значения
применяется, когда визуализируемый примитив использует примитивы лишь из части массива (например, вершины разных примитивов хранятся в одном большом общем массиве).
primitiveCount – количество примитивов, которые будут визуализированы.
Резюмируем всѐ вышесказанное. Для визуализации примитива приложение должно выполнить следующие шаги: 1.
Создать массив с информацией о вершинах примитива.
2.
Создать декларацию формата вершины. В большинстве случаев первые два шага логичнее всего выполнять один раз при запуске приложения, например, в обработчике события Load.
3.
В
обработчике
события
Paint
первым
делом
необходимо
очисть
экран
методом
GraphicsDevice.Clear.
4.
Поместить в массив координаты и цвет вершин (если координаты вершин не меняются в процессе выполнения приложения, эту операцию разумно будет вынести в метод Load).
5.
Указать декларацию формата вершины, присвоив свойству GraphicsDevice. VertexDeclaration декларацию, созданную на втором этапе.
6.
Нарисовать набор примитивов вызовом метода GraphicsDevice.DrawUserPrimitives.
7.
Показать полученное изображение на экране, переключив буферы методом Device.Present.
Однако, это ещѐ не всѐ. Дело в том, что все современные GPU содержан специализированные векторные процессоры, используемые для трансформации вершин и закраске примитивов. Так как эти процессоры принимают участие при визуализации при любых примитивов, приложение должно запрограммировать их на выполнения требуемых преобразований. Если этого не сделать, результат вызова метода DrawUserPrimitives будет не предсказуемым23. Программирование вершинных и пиксельных процессоров будет подробно рассмотрено в следующем разделе.
2.3. Введение в HLSL В этой разделе мы познакомимся с языком High Level Shader Language (язык высокого уровня для программирования шейдеров), или сокращѐнно HLSL. HLSL используется для программирования вершинных и пиксельных процессоров графического ускорителя. Программа для вершинного процессора называется вершинным шейдером, а для пиксельного процессора – пиксельным шейдером. Поддержка шейдеров впервые появилась в 8-й версии DirectX. Правда шейдеры DirectX 8 имели множество ограничений и программировались на низкоуровневом ассемблеро-подобном языке, однако в 9-й версии DirectX возможности шейдеров значительно возросли, что привело появлению к подробности в языках высокого уровня. Было создано несколько языков высокого уровня для написания шейдеров DirectX24, однако стандартом де-факто стал язык HLSL, входящий в состав DirectX 9. В XNA Framework шейдеры так же пишутся языке HLSL, а сам XNA Framework при работе с шейдерами на платформе Windows в значительной степени опирается на функциональность DirectX. Так язык HLSL тесно связан с архитектурой графического процессора, мы начнѐм этот раздел с знакомства с основами архитектуры современного графического процессора.
23
Вызов метода DrawUserPrimitives без явного задания вершинных и пиксельных шейдеров привет к генерации исключения System.InvalidOperationException с сообщением Both a valid vertex shader and pixel shader (or valid effect) must be set on the device before draw operations may be performed. 24
Например, NVIDIA Cg [К.16], [С.3]
2.3.1. Графический конвейер В разделе 2.2 вы получили представлении о визуализации примитивов средствами XNA Framework. При этом собственно процесс визуализации изображения (метод GraphicsDevice.DrawUserPrimitives) оставался для нас чѐрным ящиком. Настало время наверстать упущенное. И так, при вызове метода GraphicsDevice.DrawUserPrimitives вершины из графического буфера поступают на обработку в графический конвейер XNA Framework, представляющий собой последовательность ступеней (простых операций), выполняемых над вершинами в определѐнном порядке (рисунок 2.2). Рассмотрим эти ступени в порядке выполнения:
Вершины
Треугольники, отрезки, точки
Массив вершинных процессоров (вершинный шейдер)
Растеризация
Трансформированные вершины
Массив пикселей
Преобразование в оконные координтаты
Массив пиксельных процессоров (пиксельный шейдер)
Трансформированные вершины в оконных коордиатах
Постобработка пикселей
Сборка примитивов
Кадровый буфер
Приложение
Рисунок 2.2. Упрощенная схема графического конвейера
8. Вначале вершины обрабатываются вершинным процессором по программе, называемой вершинным шейдером. На выходе из вершинного процессора получаются так называемые трансформированные (преобразованные) вершины. К вершинам могут быть “привязаны” различные параметры: цвет вершины, текстурные координаты25 и так далее. Координаты трансформированных вершин задаются в логической системе однородных координат, называемой clip space. Однородные координаты вершины определяются четырьмя числами: (x, y, z, w). Перевод однородных координат в обычные геометрические осуществляется путѐм деления первых трех компонентов на четвертый компонент w:
x y z ( , , ). w w w
Например, вершине с однородными координатами (1, 2, 3, 4) в трѐхмерном пространстве соответствует точка с координатами
1 2 3 ( , , ) (0.25,0.5,0.75) Использование четвертого компонента обусловлено 4 4 4
рядом особенностей алгоритмов визуализации трехмерных изображений, используемых в 3D графике. При визуализации двухмерных изображений компонент w обычно полагают равным 1. В этом случае нижнему левому углу клиентской области формы соответствует точка с координатами (-1, -1, 0, 1), правому верхнему углу клиентской области – (1, 1, 0, 1), а центру клиентской области – соответственно (0, 0, 0, 1). 25
Текстурные координаты будут рассмотрены в разделе 5.x.
9. На следующей ступени графического конвейера видеокарта производит преобразование координат вершины из логической системы координат в оконную. По-умолчанию координаты трансформируются таким образом, чтобы растянуть изображение на всю поверхность элемента управления. В большинстве случаев этот процесс полностью прозрачен для приложения. П р им еч а н ие DirectX позволяет программисту задавать координаты вершин в оконных координатах. В этом случае, при вызове метода Device.DrawUserPrimitives вершины сразу поступают на третью стадию графического конвейера, минуя первую и вторую стадии. Managed DirectX и XNA Framework Beta 1 позволяют задавать координаты в оконной системе координат, однако начиная с XNA Framework Beta 2 эта функциональность почему-то пропала. По видимости, это обусловлено стремлением сделать XNA Framework как можно более платформо-независимый.
10. Далее идѐт сборка примитивов. На этой стадии вершины объединяются в примитивы. Тип примитивов определяется первым параметром метода GraphicsDevice.DrawUserPrimitives. Так при использовании параметра PrimitiveType.TriangleStrip вершины трактуются, как опорные точки (вершины) полосы треугольников. При этом каждый треугольник из полосы является независимым примитивов и обрабатывается независимо от других треугольников этой полосы. Полосы треугольников подробно будут рассмотрены в разделе 2.6.3. 11. Затем происходит растеризация примитивов – преобразование каждого примитива в набор пикселей экрана. Параметры внутренних пикселей примитива (например, цвет) определяются путѐм интерполяции соответствующих параметров вершин вдоль поверхности примитива. Как мы увидим в следующих разделах, благодаря этой интерполяции при закраске треугольника с разноцветными вершинами образуются красивые цветовые переходы. 12. Следующий этап – обработка пикселей пиксельным процессором с использованием программы, называемой пиксельным шейдером. На вход пиксельному процессору подаются параметры пикселя (цвет, текстурные координаты и т.д.), полученные путѐм интерполяции соответствующих вершинных параметров вдоль поверхности примитива. После обработки входных параметров, пиксельный процессор возвращает цвет пикселя. Тех н ич е ск ие по др о бно ст и В современных графических процессорах имеется массив вершинных и пиксельных процессоров, что позволяет им одновременно обрабатывать несколько вершин и пикселей. Так графический процессор NV40 корпорации NVIDIA, используемый в видеокартах семейства GeForce 6800, имеет 6 вершинных и 16 пиксельных процессоров, соответственно, он может параллельно обрабатывать до 6-ми вершин и 16-ми пикселей. [С.8]
13. Полученные цвета пикселей заносятся в кадровый буфер. При этом возможно выполнение некоторой простой постобработки изображения вроде смешивания цветов при эффекте полупрозрачности. В заключении стоит отметить, что этот логический конвейер DirectX не обязательно соответствует физической организации видеокарты. К примеру, видеокарта NVIDIA GeForce 8800 GTX, основанная на GPU G80, содержит 8 универсальных блоков, которые могут выполняться как вершинные, так и пиксельные шейдеры [С.7]. После прочтения этого раздела у вас, возможно, сложились несколько сумбурные представления графическом конвейере. Ничего страшного – в следующем разделе вы познакомитесь с языком HLSL и напишете несколько шейдеров, после чего всѐ встанет на свои места. Дополнительная информация Все современные графические подсистемы построены по принципу конвейера. Идея конвейера, впервые реализованная Генри Фордом, заключается в следующем: если сложный процесс разбить на последовательность простых операций (конвейер), то на выходе конвейера мы получим производительность равную производительности самой медленной операции в этой цепочке. В качестве примера конвейера рассмотрим процесс производства популярных процессоров AMD Athlon . Создание одного процессора занимает около двух месяцев. Соответственно, при классической организации производственного процесса одно подразделение может выпускать около шести процессоров в год. Однако на современных фабриках AMD производственный процесс разбивается на 400 стадий. В процессе производства каждый будущий процессор проходит через все эти 400 стадий. Как только процессор проходит текущую стадию производства, на его место приходит следующий. В итоге, на различных стадиях производства одновременно может находиться до 400 процессоров. В итоге, на выходе конвейера получается производительность труда порядка 400 процессоров в два месяца или 2400 процессоров в год. Иными словами производительность труда вырастает примерно в 400 раз. Стороннему наблюдателю может показаться, что за день один конвейер производит около 7-ми процессоров (400/60). Но в реальности, между поступлением заготовки процессора и выходом готового процессора попрежнему проходит два месяца. Это явление получило название латентность конвейера. При нормальном
функционировании конвейера на это обстоятельство можно не обращать внимания; однако в случае неполадок латентность конвейера не замедлит проявиться. Предположим, что была обнаружена и исправлена очень опасная ошибка в архитектуре процессора, после чего исправленная версия процессора немедленно поступила в производство. Но, не смотря на всю оперативность исправления ошибки, первые исправленные образцы процессоров выйдут с конвейера лишь через два месяца. А ведь подобная задержка может принести фирме заметные убытки… Другое следствие латентности – низкая эффективность конвейера при производстве небольших партий процессоров. К примеру, при производстве одного процессора темп производства будет равен 0.017 процессоров в день (один процессор за 60 дней), при производстве 28 процессоров – 0.44 процессора в день, при 100 процессорах - уже 1.33 процессоров в день и т.д. Более-менее, нормальный темп будет достигнут только при производстве партии из нескольких тысяч процессоров (рисунок 2.3). К слову, графический конвейер не является исключением из правил. Он также малоэффективен при визуализации небольшого количества примитивов. Поэтому для эффективного использования графического конвейера программист должен стараться минимизировать количество вызов метода GraphicsDevice.DrawUserPrimitives, визуализируя за один присест как можно больше примитивов.
Рисунок 2.3. Зависимость производительности конвейера от количества выпускаемых процессоров. Производительность оценивается по числу процессоров, выпускаемых в среднем за сутки.
2.3.2. Язык HLSL В начале XXI века корпорация 3dfx работала над революционным GPU Rampage, имеющим на борту массив вершинных и пиксельных процессоров. Для программирования этих процессоров Microsoft в тесном сотрудничестве с 3dfx разработала два похожих ассемблеро-подобных языка, которые были включены в DirectX 8. Язык для программирования вершинных процессоров получил название Vertex Shader 1.0 (VS 1.0), а язык для программирования пиксельных процессоров – Pixel Shader 1.0 (PS 1.0) [С.4]. Соответственно, программы, написанные на этих языках, стали называться вершинными и пиксельными шейдерами26. К сожалению, графический процессор Rampage так и не поступил в массовое производство по финансовым причинам: компания 3dfx была объявлена банкротом и вскоре куплена NVIDIA, а проект Rampage закрыт. П р им еч а н ие Если быть более точным, зачатки пиксельных шейдеров27 впервые появились в GPU NV10 (1999 год). Однако по ряду причин Microsoft не захотела включить поддержку этих шейдеров в DirectX. В результате, с точки зрения DirectX-программиста, в NV10 отсутствует какая-либо поддержка шейдеров. Единственная возможность задействовать шейдеры NV10 – воспользоваться API OpenGL [К.17], [К.18].
26
Название шейдер (shader) обусловлено применением первых вершинных и пиксельных процессоров преимущественно для более точной передачи игры света и тени (shade) на поверхности объектов. 27 Эти шейдеры получили неофициальное обозначение Pixel Shader 0.5
Первым действительно массовым GPU с вершинными и пиксельными процессорами стал NV20 (NVIDIA GeForce3), появившийся в 2001 году. Для программирования вершинных и пиксельных процессоров NV20 корпорация Microsoft совместно с NVIDIA разработали языки Vertex Shader 1.1 и Pixel Shader 1.1, являющиеся расширенными версиями Vertex Shader 1.0 и Pixel Shader 1.0. Вскоре после NV20 вышел NV25 (GeForce4), функциональность пиксельных процессоров которого была несколько расширена. Соответственно язык Pixel Shader 1.1 был обновлѐн до версии 1.328. Потом появился процессор GPU R200 (Radeon 8500) корпорации ATI и язык Pixel Shader 1.4, затем R300 (Radeon 9700 Pro) с Vertex Shader 2.0 и Pixel Shader 2.0 и так далее (см. приложение 1). В итоге к началу 2002-го года на рынке творилась полная неразбериха среди языков программирования шейдеров. К счастью Microsoft предвидела подобный поворот, и поэтому заранее сделала языки Vertex Shader и Pixel Shader независимыми от системы команд графического процессора. Фактически каждая версия языка Vertex/Pixel Shader является языком программирования для некоторого виртуального процессора, приближенного к некоторому реальному прототипу. Компиляция шейдера в систему команд физического процессора происходит непосредственно перед загрузкой шейдера в GPU. Таким образом, языки Vertex Shader и Pixel Shader являются аналогами языка IL в .NET. Независимость языков Vertex Shader и Pixel Shader от системы команд физического процессора теоретически позволяет GPU выполнять любой ассемблерный код, независимо о версии шейдера. Например, GPU R200 корпорации ATI наряду с родными Pixel Shader 1.4 может выполнять Pixel Shader 1.0, Pixel Shader 1.1, Pixel Shader 1.2 и Pixel Shader 1.3. Это достигается путѐм перекомпиляции чужеродных шейдеров в родной код. К сожалению, обратное преобразование не всегда возможно. Например, R200 не может выполнять Pixel Shader 2.0, так как программа, использующая продвинутые возможности этой версии шейдеров не может быть втиснута в прокрустово ложе архитектуры R200. По мере роста возможностей GPU программы для вершинных и пиксельных процессоров становились всѐ сложение и сложнее. Например, если в Pixel Shader 1.1 длина программы не могла превышать 16 ассемблерных команд, то в Pixel Shader 2.0 максимально возможное число ассемблерных инструкций превысило сотню. Соответственно возрастала трудоѐмкость разработки и поддержки шейдеров с использованием ассемблера-подобного языка. Таким образом, возникла реальная потребность в переходе на языки программирования шейдеров высокого уровня. В 2002 году Microsoft выпустила высокоуровневый язык программирования шейдеров High Level Shader Language (HLSL). HLSL – это язык программирования высокого уровня, предназначенный для написания программ (шейдеров) для вершинных и пиксельных процессоров. HLSL является C-подобным языком программирования с многочисленными заимствованиями из C++ и C#. В тоже время в HLSL имеется ряд важных расширений, полезных при программировании графического процессора. Программа, написанная на HLSL, компилируется в один из ассемблеро-подобных языков DirectX. Таким образом, процесс компиляции HLSL программы очень напоминает компиляцию C#-программы сначала на промежуточный язык (IL), а затем в машинный для конкретного центрального процессора (рисунок 2.4).
Программа на HLSL
Ассемблероподобный язык (Vertex/Pixel Shader)
Машинный код для графического процессора
Рисунок 2.4. Компиляция HLSL-программы.
Самой крупной логической единицей HLSL является эффект (Effect), хранящийся в отдельном текстовом файле с расширением .fx. В принципе, эффект можно считать аналогом материала в 3DS MAX. Каждый эффект состоит из одной или нескольких техник (technique). Техника – это способ визуализации материала. Например, эффект визуализации мраморного материала может содержать три техники для различных графических процессоров: технику High для ускорителей класса High End, Medium для ускорителей среднего класса, и Low – максимальная производительность при низком качестве изображения 29. Каждой технике сопоставлен пиксельный и вершинный шейдер, при этом несколько техник могут использовать общий шейдер.
28
Для программирования пиксельных процессоров NV25 (GeForce4) планировалось использовать язык Pixel Shader 1.2. Однако после выхода NV25 оказалась, что его функциональность несколько шире, чем предполагалось. Соответственно язык Pixel Shader 1.2 оказался не удел, и вскоре был обновлѐн до версии 1.3. 29 Количество техник и их названия могут быть произвольными.
Типы данных Как известно, лучший способ изучить новый язык программирования – написать на нѐм несколько программ. Так мы и поступим. Для начала мы создадим простейший эффект, закрашивающий примитив цветом морской волны (aqua). Эффект будет содержать одну технику, которую мы назовѐм Fill. И так, приступим. Мы начнѐм с написания программы для вершинного процессора: вершинного шейдера. Наш шейдер будет принимать в качестве параметра координаты вершины в обычных декартовых координатах, а возвращать координаты вершины уже в однородных координатах. Всѐ преобразование будет сводиться к добавлению к координатам вершины четвѐртого компонента (w), равного 1 (листинг 2.1). Листинг 2.1. float4 MainVS(float3 pos) { return float4(pos, 1.0); }
Как видно, программа, написанная на HLSL, очень напоминает обычную C-программу: мы объявляем функцию MainVS, которая принимает в качестве параметра переменную типа float3, а возвращает значение типа float4. Что это за такие странные типы float3 и float4, которых нет ни в C, ни C++, ни в C#? Чтобы ответить на этот вопрос мы рассмотрим встроенные типы HLSL.
Скалярные типы. В HLSL все встроенные типы делятся на две большие группы: скалярные и векторные. Скалярные типы данных являются аналогами встроенных типов данных языка C (таблица 1.1). Таблица 1.1. Скалярные типы Тип
Описание
bool
Логический тип, который может принимать значения true или false
int
32-х битное целое число
half
16-ти битное число с плавающей точкой
float
32-х битное число с плавающей точкой
double
64-х битное число с плавающей точкой
Задавая тип переменной, вы просто указываете компилятору, что вы хотели бы использовать переменную этого типа. Если текущий ускоритель не поддерживает некоторые типы данных, используемые в программе, то при компиляции шейдера в машинный код они будут заменены ближайшими аналогами30. Например, тип 31 double может быть заменѐн на тип float, half или какой-нибудь иной внутренний тип . Поэтому программист должен стараться избегать жѐсткой привязки к точности и допустимому диапазону значений используемого типа данных. Особенно это актуально для типа int, так как подавляющее большинство современных ускорителей не поддерживают тип int, в результате чего он эмулируется посредством одного из вещественных типов. Допустим, у нас имеется следующий код: // a присваивается значение 5 int a = 5; // b должно быть присвоено значение 1 int b = a / 3; // c должно стать равно 2 int c = b * 2;
Какой код будет сгенерирован компилятором? Трудно дать однозначный ответ. В большинстве случаев компилятор просто заменяет типы int, к примеру, на float: // a присваивается значение 5.0 float a = 5.0; 30
Это лучше, чем прерывать работу программы с сообщением об ошибке. Например, GPU семейства NV3x (GeForce FX) в дополнение к half и float поддерживают 12-битный вещественный формат с диапазоном значений от -2 до 2. А GPU ATI R3xx/R4xx поддерживают 24-х битный формат с плавающей запятой. Подробную информацию о типах данных, поддерживаемых GPU корпораций ATI, NVIDIA и Intel, можно найти в приложении 5. 31
// b будет присвоено значение 1.66667 float b = a / 3.0; // c станет равно 3.33334 float c = b * 2.0;
Думаю, это совершенно не то результат, который вы ожидали. Однако в ряде случаев компилятор HLSL всѐ же может начать скрупулезно эмулировать тип int посредством float: // a присваивается значение 5.0 float a = 5.0; // Значение b вычисляется посредством целочисленного деления float b; // Выполняем обычно вещественное деление float fd = a / 3.0; // Находим дробную часть от деления float ff = frac(fd); // Получаем целую часть b = fd - ff; // Если частное меньше нуля, а дробная часть не равна 0, корректируем результат. Это // обусловлено тем, что frac(2.3) = 0.3, но frac(-2.3) = 0.7 if ((fd < 0) && (ff > 0)) b = b + 1; // c станет равно 2.0 float c = b * 2.0;
Нетрудно заметить, что обратной стороной подобной эмуляции является существенно падение производительности шейдера. Из-за множества нюансов, заранее достаточно трудно предугадать, какой из двух подходов будет выбран компилятором HLSL. Единственным надежным решением является внимательный анализ кода ассемблерного кода шейдера32. Поэтому рекомендуется, по возможности, избегать использования типа int в коде шейдера за исключением переменных счетчиков цикла и индексов массивов.
Векторные типы. Большинство данных, используемых в трѐхмерной графике, является многомерными векторами, размерность которых редко превышает 4. Так, координаты точки в трѐхмерном пространстве задаются трѐхмерным вектором, цвет пикселя – четырѐхмерным вектором (три цвета и альфа-канал) и так далее. Соответственно, все современные GPU являются векторными процессорами, способными одновременно выполнять одну операцию сразу над набором из четырѐх чисел (четырѐхмерным вектором). В HLSL имеется множество типов для работы с векторами размерностью от 2-х до 4-х. Вектор из N элементов типа type задаѐтся с использованием синтаксиса, отдалѐнно напоминающего обобщенные (Generic) классы из C#: vector
где
type – имя базового типа: bool, int, half, float или double;
size – размерность вектора, которая может быть равна 1, 2, 3 или 4.
Ниже приведѐн пример объявления переменной v, являющейся вектором из четырѐх чисел типа float. vector v;
Однако на практике обычно используется сокращѐнная запись по схеме: {type}{N}
где
type – имя базового типа
N – размерность вектора.
Таким образом, вышеприведѐнное определение переменной v можно переписать следующим образом: float4 v; 32
Основы ассемблероподобных языков Vertex Shader и Pixel Shader будут рассмотрены в четвертой главе.
Язык HLSL позволяет инициализировать вектор двумя способами. Первый способ – перечислить значения вектора в фигурных скобках на манер инициализации массивов в языке C. Ниже приведѐн пример, присвоения четырѐхмерному вектору v начального значения
(0.2, 0.4, 0.6, 0.8) .
float4 v={0.2, 0.4, 0.6, 0.8};
Другой способ – создать новый вектор с использованием конструктора и присвоить его вектору v: float4 v=float4(0.2, 0.4, 0.6, 0.8);
Любой N мерный вектор имеет множество конструкторов, которые могут принимать в качестве параметров как скалярные типы, так и векторы.. Единственное ограничение: общее количество всех компонентов векторов и скалярных типов должно быть равно N. Подобное многообразие конструкторов даѐт программисту потрясающую гибкость при инициализации векторов: // Создаѐм двухмерный вектор и присваиваем ему значение (0.1, 0.2) float2 a={0.1, 0.2}; // Создаѐм ещѐ один двухмерный вектор и присваиваем ему значение (0.3, 0.4) float2 b=float2(0.3, 0.4); // Создаѐм трѐхмерный вектор. Конструктору в качестве параметра передаѐтся вектор “b” и число // 1.0. Соответственно вектору c будет присвоено значение (0.3, 0.4, 1.0) float3 c=float3(b, 1.0); // Создаѐм четырѐхмерный вектор на основе скалярного типа и трѐхмерного вектора. Итоговое // значение вектора d будет равно (0.7, 0.3. 0.4, 1.0) float4 d=float4(0.7, c); // Создаѐм четырѐхмерный вектор на основе двух двухмерных. В результате вектору “d” будет // присвоено значение (0.1, 0.2. 0.3, 0.4) float4 e=float4(a, b);
Семантики Думаю, после такого небольшого экскурса в HLSL вы без труда сможете разобраться в тексте вершинного шейдера из листинга 2.1. Однако если быть более точным, функция, приведѐнная в этом листинге, не является полноценным шейдером. С точки зрения DirectX это всего лишь простая функция, принимающая в качестве параметра трѐхмерный вектор и возвращающая четырѐхмерный вектор. Чтобы превратить эту функцию в вершинный шейдер, мы должны связать параметр Pos с координатами вершины, а результаты функции – с итоговыми координатами вершины. В HLSL для этой цели используются так называемые семантики (semantics), предназначенные для связи между собой данных, проходящих через различные ступени графического конвейера. В таблице 2.2 приведены некоторые семантики для входящих данных вершинного шейдера. Описание всех семантик HLSL можно найти в приложении 3. П р им еч а н ие Теоретически вершина может содержать несколько цветов, геометрических координат и т.п. Чтобы различать их в названии семантики требуется указывать целочисленный индекс. При отсутствии индекса в названии семантики он полагается равным 0. Применение семантик с индексами будет рассмотрено в пятой главе.
Таблица 2.2. Некоторые семантики входных данных вершинного шейдера Семантика
Описание
POSITION[n]
Координаты вершины
COLOR[n]
Цвет вершины
PSIZE[n]
Размер точки (при визуализации набора точек)
Для связи параметра функции с входными данными шейдера, после объявления параметра укажите знак двоеточия и название соответствующей семантики. Таким образом, для связи параметра pos функции MainVS с координатами вершины необходимо использовать семантику POSITION (листинг 2.2). Листинг 2.2. float4 MainVS(float3 pos:POSITION) { return float4(pos, 1.0); }
Теперь нам надо указать, что функция MainVS возвращает трансформированные координаты вершины. Для этого в HLSL используются семантики выходных данных вершинного шейдера. В частности, для указания того факта, что шейдер возвращает трансформированные координаты вершины используется семантика POSITION (листинг 2.3). Листинг 2.3. float4 MainVS(float3 pos:POSITION):POSITION { return float4(pos, 1.0); }
Вот теперь мы наконец-то получили полноценный вершинный шейдер. Следующий этап – написание пиксельного шейдера. Наш первый пиксельный шейдер будет просто закрашивать все пиксели цветом морской волны (aqua) (листинг 2.4). Листинг 2.4. float4 MainPS() : COLOR { return float4(0.0, 1.0, 1.0, 1.0); }
П р им еч а н ие В HLSL минимальной яркости цветового канала соответствует значение 0.0, а максимальной 1.0.
Так как этот шейдер будет выполняться для каждого пикселя визуализируемого примитива, все пиксели примитива окрасятся в цвет морской волны. Семантика COLOR указывает DirectX, что результат работы пиксельного шейдера MainPS является итоговым цветом пикселя.
Техники, проходы и профили И так, у нас имеются программы для вершинного и пиксельного процессора – вершинный и пиксельный шейдеры. Заключительный этап написания эффекта – создание техники (technique), использующей этот шейдеры. Ниже приведено определение техники с названием Fill, использующей вершинный шейдер MainVS и пиксельный шейдер MainPS (листинг 2.5). Листинг 2.5. technique Fill { pass p0 { VertexShader = compile vs_1_1 MainVS(); PixelShader = compile ps_1_1 MainPS(); } }
Как видно, техника определяется с использованием ключевого слова technique. Каждая техника содержит один или несколько проходов, объявляемых с использованием ключевого слова pass. В свою очередь каждому проходу ставится в соответствие пиксельный и вершинный шейдер. Наша техника Fill содержит единственный проход с названием p0. П р им еч а н ие Многопроходные техники используются для создания сложных спецэффектов, которые не могут быть визуализированы за один проход графического конвейера.
Вершинный шейдер для каждого прохода (pass) задаѐтся с использованием следующего синтаксиса: VertexShader = compile {используемый профиль} {вершинный шейдер};
Пиксельный шейдер задаѐтся аналогично: PixelShader = compile {используемый профиль} {пиксельный шейдер};
Профиль шейдера (shader profile) задаѐт промежуточный ассемблеро-подобный язык, на который будет скомпилирован шейдер. Кроме того, профиль задаѐт некоторые архитектурные особенности целевого графического процессора, которые будут учтены компилятором при генерации промежуточного ассемблерного кода. В большинстве случаев каждой версии шейдеров соответствует один профиль. Например, языку Vertex Shader 1.1 соответствует профиль vs_1_1; Pixel Shader 1.4 – профиль ps_1_4, Pixel Shader 2.0 – профиль ps_2_0 и так далее. Однако некоторым языкам вроде Pixel Shader 2.x соответствует два профиля: в данном случае это ps_2_a и ps_2_b, при этом первый профиль генерирует код Pixel Shader 2.x, оптимизированный под архитектуру NV3x, а второй – для R4xx. В таблицах 2.3 и 2.4 приведено соответствие между профилями и соответствующими версиями шейдеров. Таблица 2.3. Профили вершинных шейдеров Профиль
Версия вершинных шейдеров
vs_1_0
1.0
vs_1_1
1.1
vs_2_0
2.0
vs_2_a
2.x
vs_3_0
3.0
Таблица 2.4. Профили пиксельных шейдеров Профиль
Версия пиксельных шейдеров
ps_1_0
1.0
ps_1_1
1.1
ps_1_2
1.2
ps_1_3
1.3
ps_1_4
1.4
ps_2_0
2.0
ps_2_a
2.x (оптимизация для NV3x)
ps_2_b
2.x (оптимизация для R4xx)
ps_3_0
3.0
Большинство видеокарт поддерживает несколько профилей вершинных и пиксельных шейдеров (см. приложение 2). В результате каждый разработчик сталкивается с проблемой выбора используемого профиля. В большинстве случаев выбор версии шейдеров определяется минимальными требованиями к приложению. Допустим, необходимо, чтобы наша программа могла работать на видеокартах класса ATI Radeon 9500 (R3xx) и выше, NVIDIA GeForce FX 5200 (NV3x) и выше, а так же Intel GMA 900 и выше. Изучив приложение 2, мы увидим, что все видеокарты, удовлетворяющие этому критерию, поддерживают профили вершинных шейдеров vs_1_0, vs_1_1, vs_2_0 и профили пиксельные шейдеров ps_1_0, ps_1_1, ps_1_2, ps_1_3, ps_1_4 и ps_2_0. Таким образом, мы можем смело использовать профили vs_2_0 и ps_2_0 для всех шейдеров. При этом для некоторых эффектов можно предусмотреть дополнительные техники (technique) для видеокарт класса High End, использующих профили vs_3_0 и ps_3_0. П р им еч а н ие GPU семейства NV3x демонстрируют очень низкую производительность при использовании профилей пиксельных шейдеров ps_2_0 и ps_2_a ([С.5], [С.6]). Если для вас актуальна производительность вашего приложения на этих GPU, то имеет смысл стараться по возможности использовать профиль ps_1_4 вместо ps_2_0. Другой вариант – предусмотреть отдельные упрощѐнные техники для NV3x, использующие профили ps_1_4.
В примерах этой книги я буду стараться использовать минимальную версию профилей, необходимую для нормальной компиляции шейдеров. В частности, именно по этой причине, наш эффект Fill использует профили vs_1_1 и ps_1_1: это позволит работать нашему эффекту даже на стареньких видеокартах семейства GeForce3 (NV20).
И так, у нас есть вершинный и пиксельный шейдеры, а так же техника Fill, использующая эти шейдеры. Для получения готового эффекта осталось только помесить их в файл с расширением *.fx, например, в SimpleEffect.fx (листинг 2.6). Листинг 2.6. // Вершинный шейдер. Принимает координаты вершины (x, y, z). Возвращает – координаты вершины // в однородных координатах (x, y, z, 1.0) float4 MainVS(float3 pos:POSITION):POSITION { return float4(pos, 1.0); } // Пиксельный шейдер. Закрашивает все пиксели примитива цветом морской волны. float4 MainPS():COLOR { return float4(0.0, 1.0, 1.0, 1.0); } // Техника Fill technique Fill { // Первый проход pass p0 { // Задаѐм вершинный шейдер для техники. Для компиляции шейдера используется профиль vs_1_1 VertexShader = compile vs_1_1 MainVS(); // Задаѐм пиксельный шейдер. для компиляции шейдера используется профиль ps_1_1 PixelShader = compile ps_1_1 MainPS(); } }
Теперь мы должны научиться использовать этот эффект в наших C#-приложениях.
2.3.3. Использование эффектов в XNA Framework Одним из основных классов XNA Framework, предназначенным для работы с эффектами, является класс Effect. Класс Effect является довольно сложным классом, содержащим ряд коллекций, отражающих структуру файла эффекта (рисунок 2.5). Как говорилось в прошлом разделе, в каждом эффекте HLSL имеется несколько техник, которые в свою очередь содержат несколько проходов. При этом минимально возможный эффект включает хотя бы одну технику и один проход. Соответственно, класс Effect содержит коллекцию Techniques с экземплярами классов EffectTechnique, инкапсулирующих техники. В свою очередь, каждая техника содержит коллекцию Passes экземпляров класса EffectPass с информацией об эффекте. Effect
Techniques (EffectTechnique)
Passes (EffectPass) Рисунок 2.5. Коллекции класса Effect.
Загрузка и компиляция файла эффекта. Загрузка эффекта из файла *.fx с последующей компиляцией осуществляется при помощи статического метода Effect.CompiledEffect: public static CompiledEffect CompileEffectFromFile(string effectFile, CompilerMacro[] preprocessorDefines, CompilerIncludeHandler includeHandler, CompilerOptions options, TargetPlatform platform);
где
effectFile – имя файла с эффектом.
preprocessorDefines – массив макроопределений (аналогов директивы #define в C#), используемых при компиляции эффекта. Мы будем использовать значение null.
includeHandler – объект, используемый для обработки директив #include в fx-файле. Так как наш файл не содержит директив #include, мы будем использовать значение null.
options – опции компилятора HLSL, которые задаваемые с использованием перечислимого типа CompilerOptions (таблица 2.5.). Члены типа CompilerOptions являются битовыми флагами, что позволяет комбинировать их с использованием оператора |. В качестве этого параметра, как правило, передаѐтся значение CompilerOptions.None.
platform – значение перечислимого типа TargetPlatform, указывающее платформу, для которой
компилируется
эффект.
В
XNA
Framework
1.0
поддерживаются
две
платформы:
TargetPlatform.Windows и TargetPlatform.Xbox360, названия которых говорят за себя. Все примеры этой книги будут использовать значение TargetPlatform.Windows.
Таблица 2.5. Некоторые члены перечислимого типа CompilerOptions. Член перечисления
Значение
None
Нет никаких опций
Debug
Вставляет в ассемблерный код отладочную информацию
NotCloneable(*)
Запрещает клонирование (создании копии) эффекта при помощи метод Clone. Эта опция уменьшает объѐм используемой памяти, так как в оперативной памяти не хранится информация, необходимая для клонирования эффекта. При этом экономия оперативной памяти достигает 50%.
ForceVertexShaderSoftwareNoOptimizations
Форсирует компиляцию вершинного шейдера с использованием максимально возможной версии Pixel Shader (на момент написания книги это 3.0), не взирая на возможности текущего графического устройства.
ForcePixelShaderSoftwareNoOptimizations
Форсирует компиляцию пиксельного шейдера с использованием максимально возможной версии Pixel Shader (на момент написания книги это 3.0), не взирая на возможности текущего графического устройства.
PartialPrecision
Использовать минимальную точность вычислений, поддерживаемую текущим графическим устройством. Как правило, при использовании этой опции типы double и float заменяются на half.
SkipOptimization
Отключает оптимизацию кода.
SkipValidation
Отключает проверку соответствия сгенерированного кода возможностям текущего ускорителя (не превышено ли ограничение на максимальную длину программы и т.д.) перед отправкой откомпилированного кода шейдера в драйвер. Этот флаг полезен в тех случаях, когда драйверу всѐ же удаѐтся оптимизировать слишком длинный ассемблеро-подобный код таким образом, чтобы уложиться в ограничения архитектуры графического процессора.
(*) – не поддерживается методом CompileEffectFromFile. Если метод Effect.CompileEffectFromFile не сможет открыть fx-файл (например, из-за его отсутствия), то будет сгенерировано одно из исключений производных от System.IO.IOException вроде System.IO.FileNotFoundException или System.IO.DirectoryNotFoundException. Метод CompileEffectFromFile возвращает структуру CompiledEffect, содержащую откомпилированный код эффекта, а так же отчет о компиляции эффекта (возникли ли какие-либо проблемы при компиляции эффекта и т.п.). public struct CompiledEffect {
// Сообщения о проблемах, возникших при компиляции эффекта public string ErrorsAndWarnings { get; } // Был ли эффект откомпилирован удачно public bool Success { get; } // Если свойство Success равное true, содержит откомпилированный код эффекта public byte[] GetEffectCode(); ... }
Стоит отметить, что метод GetEffectCode возвращает байт-код промежуточного языка наподобие того, что содержится в exe-файлах для платформы .NET. Соответственно, этот код с точки зрения человека является лишь бессмысленным набором байт. Тем не менее, как мы увидим далее, при необходимости этот байт-код может быть легко дизассемблирован удобочитаемый текстовый вид. П р им еч а н ие При желании приложение может сохранить откомпилированный байт-код в каком-нибудь файле, и при следующих запусках считывать из файла уже готовый откомпилированный байт-код. Кстати, Visual C# 2005 Express33 при компиляции проектов, использующих Content Pipeline, автоматически выполняет компиляцию fxфайлов проекта и сохраняет полученный промежуточный код в файлах с расширением nvb. Таким образом, приложениям, использующим Content Pipeline, нет нужды самостоятельно компилировать fx-файлы.
Следующий этап – компиляция байт-кода промежуточного языка в машинный код вершинных и пиксельных процессоров текущей видеокарты. Эта операция автоматически осуществляется конструктором класса Effect: public Effect(GraphicsDevice graphicsDevice, byte[] effectCode, CompilerOptions options, EffectPool pool);
где
graphicsDevice – устройство Direct3D, которое будет использоваться для работы с эффектом
byte[] effectCode –код CompileEffectFromFile.
options – опции компилятора, задающиеся использованием перечислимого типа CompilerOptions
(таблица
2.5.).
Довольно
эффекта, предварительно скомпилированный при помощи метода
часто в качестве этого параметра передаѐтся значение что позволяет несколько сэкономить объем используемой
CompilerOptions.NotCloneable,
оперативной памяти.
pool – экземпляр класса EffectPool, позволяющий нескольким эффектам использовать общие
параметры. В наших первых примерах мы будем использовать не более одного fx-файла, этот параметр будет равен null. После вызова конструктора класса Effect мы наконец-то получим готовую технику. Теперь нам необходимо выбрать одну из техник эффекта и проверить еѐ поддержку текущей видеокартой. Техники эффекта хранятся в коллекции Techniques эффекта: public EffectTechniqueCollection Techniques { get; }
Однако XNA-приложения достаточно редко обращаются к этой коллекции. Дело в том, что конструктор класса Effect автоматически находит первую попавшуюся технику эффекта и присваивает еѐ свойству CurrentTechnique. public EffectTechnique CurrentTechnique { get; set; }
Соответственно, если эффект содержит лишь единственную технику, приложению для получения информации об этой техники достаточно обратиться к свойству CurrentTechnique, возвращающему экземпляр класса EffectTechnique, инкапсулирующий технику эффекта. Ниже приведено сокращенное определение класса EffectTechnique: public sealed class EffectTechnique { // Название техники public string Name { get; } // Коллекция проходов техники public EffectPassCollection Passes { get; } // Выполняет валидацию техники public bool Validate(); 33
В Visual Studio 2005 Pro эта функциональность в настоящее время не доступна.
... }
И так каждый эффект может содержать несколько техник. При этом некоторые техники эффекта могут нормально работать на текущем GPU, а некоторые (наиболее продвинутые) нет. Если требования техники (technique) превышают возможности текущего GPU (например, пользователь пытается запустить эффект использующий профиль ps_1_4 на NV2x), XNA Framework проигнорирует технику. В результате примитивы, использующие эту технику, будут отображаться некорректно 34. Во избежание подобных неприятностей необходимо заранее проверить возможность выполнения данной техники средствами текущего графического устройства. Для этой цели в классе EffectTechnique предусмотрен метод Validate. Если техника может быть выполнена на текущем устройстве, метод Validate возвращает значение true, иначе – false. Во втором случае, приложение может попытаться подобрать альтернативную технику с меньшими системными требованиями или завершить приложение с сообщением о недостаточной “мощности” текущей видеокарты. Резюмируя всѐ вышесказанное можно предположить, что код для загрузки эффекта и выбора техники, как правило, имеет следующую структуру: GraphicsDevice device; // Флаг, устанавливаемый в значение true при аварийном завершении работы приложения из-за // проблем в обработчике события Load closing = false; ... // Этот код обычно размещается в обработчике события Load. CompiledEffect compiledEffect; try { // Загружаем эффект из файла и компилируем в промежуточный код compiledEffect = Effect.CompileEffectFromFile(effectFileName, null, null, CompilerOptions.None, TargetPlatform.Windows); } // Если при загрузке файла эффекта возникли проблемы catch (IOException ex) { // Выводим сообщение об ошибке MessageBox.Show(ex.Message, "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); // Завершаем работу приложения. Так как метод Close() нельзя вызвать из обработчика // события Load, приходится идти на хитрости (использовать обработчик события // Application.Idle, вызывающий внутри себя метод Close главной формы приложения, если // флаг closing равен true). closing = true; Application.Idle += new EventHandler(Application_Idle); return; } // Если эффект был скомпилирован с ошибками if (!compiledEffect.Success) { // Выдаем сообщение об ошибке MessageBox.Show(String.Format("Ошибка при компиляции эффекта: \r\n{0}", compiledEffect.ErrorsAndWarnings), "Критическая ошибка", MessageBoxButtons.OK, 34
Как правило, такие примитивы просто закрашиваются чѐрным цветом.
MessageBoxIcon.Error); closing = true; Application.Idle += new EventHandler(Application_Idle); return; } // Компилируем байт-код промежуточного языка и создаем объект эффекта. effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); // Если текущая техника не может выполнена на текущем графическом устройстве. if (!effect.CurrentTechnique.Validate()) { // Выводим сообщение об ошибке и завершаем работу приложения MessageBox.Show(String.Format("Ошибка \"{1}\"\n\r" +
при
валидации
техники
\"{0}\"
эффекта
"Скорее всего, функциональность шейдера превышает возможности GPU", effect.CurrentTechnique.Name, MessageBoxButtons.OK,
effectFileName),
"Критическая
ошибка",
MessageBoxIcon.Error); closing = true; Application.Idle += new EventHandler(Application_Idle); return; }
Визуализация объекта, использующего эффект. Визуализация примитивов, использующих эффект, начинается с вызова метода Effect.Begin: public void Begin();
Далее приложение должно перебрать все (CurrentTechnique) и для каждой техники:
проходы
(коллекция
passes)
текущей
техники
14. Вызвать метод Pass текущего эффекта. 15. Визуализировать примитивы с использованием метода GraphicsDevice.DrawUserPrimitives. 16. Вызывать метод End текущего эффекта. По окончанию визуализации эффекта приложение должно вызвать метод Effect.End. В итоге код визуализации примитива выглядит следующим образом: Effect effect; ... // Фрагмент типового обработчика события Paint ... // Начинаем визуализацию примитивов с использованием эффекта effect. effect.Begin(); // Перебираем все проходы визуализации текущей техники foreach (EffectPass pass in effect.CurrentTechnique.Passes) { // Начинаем визуализацию текущего прохода effect.Begin(); // Визуализируем примитивы device.DrawUserPrimitives(...); ... device.DrawUserPrimitives(...); // Завершаем проход effect.End(); } // Заканчиваем визуализацию эффекта
effect.End();
Ну что ж, этих знаний вполне достаточно, для того, чтобы попробовать свои силы в визуализации простых примитивов. П р им еч а н ие Как известно, оператор foreach, используемый нами для перебора коллекции проходов (effect.CurrentTechnique.Passes), обладает несколько более низкой производительностью по сравнению с классическим оператором for. Однако при небольшом количестве итераций эта особенность не является сколь либо заметным недостатком. Более подробно эта тема будет рассмотрена в разделе 3.3.4.
2.4. Точки (PrimitiveType.PointList). Как известно, иногда лучше один раз увидеть, чем сто раз услышать. Эта простая истина как никогда подходит к XNA Framework с весьма запутанной технологией визуализации примитивов. Поэтому мы начнѐм изучение материала с разбора приложения, рисующего в центре экрана одну точку цвета морской волны (листинг 2.7). Листинг 2.7. // Пример Examples\Ch02\Ex01 // Стандартные директивы C# using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Text; using System.Windows.Forms; // При обработке исключений, связанных с открытием файла эффекта, нам понадобится // пространство имен System.IO using System.IO; // Включаем в приложение пространства имен XNA Framework using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using XnaGraphics = Microsoft.Xna.Framework.Graphics; namespace GSP.XNA.Book.Ch02.Ex01 { public partial class MainForm : Form { // Устройство XNA Framework GraphicsDevice device = null; // Параметры представления данных на экране PresentationParameters presentParams; // Графический буфер для хранения вершин (то есть координат нашей точки) VertexPositionColor[] vertices = null; // Декларация формата вершины VertexDeclaration decl = null; // Эффект, используемый при визуализации точки Effect effect = null; // Флаг, устанавливаемый в true при подготовке к завершении работы приложения bool closing = false; public MainForm() { InitializeComponent();
} private void MainForm_Load(object sender, EventArgs e) { // Стандартная процедура настройки параметров формы и создание графического устройства SetStyle(ControlStyles.Opaque | ControlStyles.ResizeRedraw, true); MinimumSize = SizeFromClientSize(new Size(1, 1)); presentParams = new PresentationParameters(); presentParams.IsFullScreen = false; presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; presentParams.BackBufferWidth = ClientSize.Width; presentParams.BackBufferHeight = ClientSize.Height; device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, CreateOptions.SoftwareVertexProcessing | CreateOptions.SingleThreaded, presentParams); // Создаѐм массив, предназначенный для хранения координат одной точки vertices = new VertexPositionColor[1]; // Создаем декларацию формата вершины decl = new VertexDeclaration(device, VertexPositionColor.VertexElements); // Задаѐм координаты точки (вершины) таким образом, чтобы она всегда была в центре экрана. // Цвет точки устанавливаем в морской волны, но в действительности он не влияет на цвет // точки, так как используемый эффект игнорирует информацию о цвете вершины vertices[0] = new VertexPositionColor(new Vector3(0.0f, 0.0f, 0.0f), XnaGraphics.Color.Aqua);
// Структура для хранения кода откомпилированного эффекта CompiledEffect compiledEffect; try { // Пытаемся загрузить эффект из файла и откомпилировать его в промежуточный байт-код compiledEffect = Effect.CompileEffectFromFile(effectFileName, null, null, CompilerOptions.None, TargetPlatform.Windows); } // Если файл с эффектом не был найден catch (IOException ex) { // Выводим сообщение об ошибке и завершаем работу приложения MessageBox.Show(ex.Message, "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); closing = true; Application.Idle += new EventHandler(Application_Idle); return; } // Если эффект не был удачно откомпилирован if (!compiledEffect.Success) { // Выводим сообщение об ошибках и предупреждениях из свойства ErrorsAndWarnings и завершаем // работу приложения MessageBox.Show(String.Format("Ошибка при компиляции эффекта: \r\n{0}", compiledEffect.ErrorsAndWarnings), "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); closing = true;
Application.Idle += new EventHandler(Application_Idle); return; } // Создаем эффект на базе скомпилированного байт-кода. Обратите на использование флага // CompilerOptions.NotCloneable,который позволяет ощутимо сократить объем оперативной // памяти, используемой эффектом effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); // Выполняем валидацию текущей техники (проверяем, может ли текущая техника выполнится на // данном GPU) if (!effect.CurrentTechnique.Validate()) { // Если функциональность текущего GPU недостаточна, выводим сообщение об ошибке MessageBox.Show(String.Format("Ошибка при валидации техники \"{0}\" эффекта \"{1}\"\n\r" + "Скорее всего, функциональность шейдера превышает возможности GPU", effect.CurrentTechnique.Name, effectFileName), "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); closing = true; Application.Idle += new EventHandler(Application_Idle); return; } } private void MainForm_Paint(object sender, PaintEventArgs e) { // Если приложение завершает работу из-за проблем в обработчике события Load, выходим из // обработчика события Paint (эффект effect может быть не корректно инициализирован, поэтому // попытка визуализации сцены может спровоцировать исключение) if (closing) return; try { // Проверяем, не потеряно ли устройство if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.Lost) throw new DeviceLostException(); if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.NotReset) device.Reset(presentParams); // Очищаем форму device.Clear(XnaGraphics.Color.CornflowerBlue); // Устанавливаем формат вершины device.VertexDeclaration = decl; // Начинаем визуализацию эффекта. effect.Begin(); // Перебираем все проходы эффекта foreach (EffectPass pass in effect.CurrentTechnique.Passes) { // Начинаем визуализацию текущего прохода pass.Begin(); // Рисуем точку device.DrawUserPrimitives(PrimitiveType.PointList, vertices, 0, vertices.Length); // Заканчиваем визуализацию прохода
pass.End(); } // Оканчиваем визуализацию эффекта effect.End(); // Завершаем визуализацию примитивов // Выводим полученное изображение на экран device.Present(); } // Обработка потери устройства catch (DeviceNotResetException) { Invalidate(); } catch (DeviceLostException) { } } // Обработчик события Idle. Завершает работу приложения. void Application_Idle(object sender, EventArgs e) { Close(); } // Сброс устройства при изменении размеров окна private void MainForm_Resize(object sender, EventArgs e) { if (WindowState != FormWindowState.Minimized) { presentParams.BackBufferWidth = ClientSize.Width; presentParams.BackBufferHeight = ClientSize.Height; device.Reset(presentParams); } } // Удаление устройства при завершении программы private void MainForm_FormClosed(object sender, FormClosedEventArgs e) { if (device != null) { device.Dispose(); device = null; } } } }
Рассмотрим наиболее интересные фрагменты программы. Вначале мы объявляем массив для хранения вершин (то есть координат нашей точки) и декларацию вершины, для хранения описания формата элементов массива: VertexPositionColor[] vertices = null; VertexDeclaration decl = null;
Ниже объявляется эффект, который будет использоваться для визуализации точки: Effect effect = null;
Инициализация всех этих объектов выполняется в обработчике события Load формы. После создания графического устройства, обработчик события Load создает массив с информацией о единственной вершине сцены и декларацию формата этой вершины: vertices = new VertexPositionColor[1]; vertices[0] = new VertexPositionColor(new Vector3(0.0f, 0.0f, 0.0f), XnaGraphics.Color.Aqua); // Описание формата вершины берется из поля VertexPositionColor decl = new VertexDeclaration(device, VertexPositionColor.VertexElements);
Далее обработчик события Load выполняет компиляцию fx-файла, после использует полученный байт-код для создания объекта эффекта: // Для сокращения объѐма кода из него исключена обработка исключительных ситуаций. В реальных // приложениях так поступать категорически не рекомендуется, так как это значительно снизит // “дуракоустойчивость” вашего приложения. Поэтому настоятельно рекомендую ознакомится с // полной версией кода из листинга 2.7. CompiledEffect compiledEffect; // Компилируем fx-файл в байт код compiledEffect = Effect.CompileEffectFromFile(effectFileName, null, null, CompilerOptions.None, TargetPlatform.Windows); // Используем полученный байт-код для создания объекта эффекта. effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null);
При возникновении ошибок при загрузке или компиляции эффекта обработчик не завершает работу приложения путем вызова метода Close формы, так как, если верить MSDN, это может вызвать утечку ресурсов. Вместо этого он регистрирует собственный обработчик события Idle, автоматически вызывающий метод Close. Но здесь есть один подводный камень: метод Idle будет вызван по завершении обработки всех событий, в том числе Paint. Таким образом, если не принять особых мер, не исключен вызов метода Idle с не полностью сформированным эффектом, что с большой долей вероятности приведет к краху приложения. Для борьбы с этим недоразумением в начале обработчика события Paint осуществляется проверка, не готовится ли приложение к завершению работы: если это так, то обработчик события Paint не выполняет визуализацию сцены. Переходим к обработчику события Paint, выполняющего визуализацию изображения. Первым делом данный обработчик выполняет стандартные проверки потери устройства, после чего очищает экран. Далее он присваивает свойству VertexDeclaration графического устройства декларацию вершины, созданную в обработчике события Load: device.VertexDeclaration = decl;
На первый взгляд эту операцию было бы рациональнее вынести в обработчик события Load. Однако это не самая лучшая идея, так как информация о параметрах графического устройства теряется при сбросе методом Reset. Следовательно, такое приложение перестало бы нормально функционировать после первой же потери устройства. И, наконец, главная изюминка программы: визуализация точки на экране с использованием эффекта: effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.PointList, vertices, 0, vertices.Length); pass.End(); } effect.End();
Как видно, смотря на обилие кода, приложение имеет достаточно простую структуру. Как говорится, у страха глаза велики. Теперь давайте попробуем создать это приложение в Visual Studio 2005. Для начала запустите Visual Studio 2005, создайте проект нового приложения Windows Forms и подключите сборку Microsoft.Xna.Framework.dll. В окне Solution Explorer щелкните правой кнопкой мыши на узле проекта и выберите в контекстном меню команду Add | New Folder, и создайте папку Data, в которой мы
будем хранить различные вспомогательные эффекты (рисунок 2.6). Затем добавьте в папку Data файл эффекта SimpleEffect.fx (рисунок 2.7), например, при помощи команды контекстного меню Add | New Item... . Поместите в файл SimpleEffect.fx текст эффекта из листинга 2.6.
Рисунок 2.6. Создание новой папки.
Рисунок 2.7. Файл SimpleEffect.fx.
После этих действий в каталоге проекта появится каталог Data, содержащий файл эффекта SimpleEffect.fx. Однако подобное расположение файла не совсем удобно, ведь при компиляции Debugверсии приложения Visual Studio копирует исполняемый exe-файл в подкаталог проекта bin\Debug, а при компиляции Release версии соответственно в каталог bin\Release. Соответственно, было бы логичным, если бы файл эффекта размещался вместе с исполняемым файлом приложения, что облегчило бы создание инсталлятора финальной версии приложения. К счастью, это достаточно легко организовать: просто выделите в окне Solution Explorer файл SimpleEffect.fx и в окне Properties присвойте свойству Copy to Output Directory значение Copy if newer (рисунок 2.8). После этого при каждой компиляции приложения Visual Studio будет автоматически создавать в подкаталоге bin\Debug или bin\Release подкаталог bin\Debug\Data или bin\Release\Data и копировать в него файл SimpleEffect.fx.
В заключении остаѐтся создать необходимые обработчики сообщений в соответствии с листингом 2.7. Полную версию приложения можно найти на CD диске с книгой в каталоге Ch02\Ex01.
Рисунок 2.8. Свойства файла SimpleEffect.fx.
2.4.1. Проверка аппаратной поддержки вершинных шейдеров. Наше приложение, визуализирующее точку в центре экрана, всегда создает графическое устройство с использованием флага CreateOptions.SoftwareVertexProcessing, то есть вершинные шейдеры всегда выполняются средствами центрального процессора (CPU). Учитывая, что подавляющее большинство современных графических процессоров имеют аппаратную поддержку вершинных шейдеров, этот недочет приводит к неоптимальному использованию ресурсов GPU. Использование флага CreateOptions.HardwareVertexProcessing тоже не является хорошей идей, так это сделает невозможной работу приложения на видеокартах без аппаратных вершинных процессоров (например, Intel GMA 900 и Intel GMA 950). Так что же делать? Наиболее красивое решение проблемы – проверка возможностей текущего GPU. Если текущий GPU имеет аппаратные вершинные процессоры, приложение должно создать устройство с использованием флага CreateOptions.HardwareVertexProcessing, в противном случае – CreateOptions.SoftwareVertexProcessing. Таким образом, нам необходимо научиться анализировать возможности текущего GPU. В XNA Framework информация обо всех возможностях графического устройства инкапсулируются в классе GraphicsDeviceCapabilities, каждое свойство которого соответствует одной из характеристик графического устройства. Учитывая многообразие характеристик устройства, разработчики сгруппировали часть свойств в логические группы (структуры), то есть некоторые свойства класса GraphicsDeviceCapabilities в свою очередь тоже содержат набор свойств по некоторой тематике: // Некоторые фрагменты определения класса GraphicsDeviceCapabilities public sealed class GraphicsDeviceCapabilities : IDisposable { // Группа свойств, описывающих возможности графического устройства по визуализации примитивов public GraphicsDeviceCapabilities.PrimitiveCaps PrimitiveCapabilities { get; } // Группа свойств с информацией о возможностях декларации вершин public GraphicsDeviceCapabilities.DeclarationTypeCaps DeclarationTypeCapabilities { get; } // Группа свойств с информацией о вершинных шейдерах public GraphicsDeviceCapabilities.VertexShaderCaps VertexShaderCapabilities { get; } // Группа свойств с информацией о пиксельных шейдерах public GraphicsDeviceCapabilities.PixelShaderCaps PixelShaderCapabilities { get; } // Группа свойств с информацией о драйвере устройства public GraphicsDeviceCapabilities.DriverCaps DriverCapabilities { get; } // Группа свойств с информацией об устройстве, которая может пригодится при создании // устройства
public GraphicsDeviceCapabilities.DeviceCaps DeviceCapabilities { get; }3 ... // Свойства без подсвойств: // Максимальная версия языка Vertex Shader, поддерживаемая графическим устройством public Version VertexShaderVersion { get; } // Максимальная версия языка Pixel Shader, поддерживаемая графическим устройством public Version PixelShaderVersion { get; } // Максимальный размер точки, которую способно отображать графическое устройство public float MaxPointSize { get; } // Максимальное количество примитивов, которое способно отобразить графическое устройство за // один вызов метода DrawUserPrimitives public int MaxPrimitiveCount { get; } // Остальные свойства ... }
Информация, которая может понадобиться при создании графического устройства, сосредоточена в свойствах свойства GraphicsDeviceCapabilities.DeviceCaps DeviceCapabilities: // Некоторые фрагменты определения структуры DeviceCaps public struct DeviceCaps { // Поддерживает ли графическое устройство метод DrawUserPrimitives на аппаратном уровне public bool SupportsDrawPrimitives2Ex { get; } // Поддерживает ли графическое устройство аппаратную растеризацию примитивов (при отсутствии // подобной поддержки визуализация будет выполняться с неприемлемо низкой // производительностью) public bool SupportsHardwareRasterization { get; } // Имеет ли графическое устройство аппаратные вершинные процессоры public bool SupportsHardwareTransformAndLight { get; } ... }
Как видно, информация о наличии аппаратных вершинных процессоров содержится в свойстве SupportsHardwareTransformAndLight. Таким образом, нашему приложению необходимо просто проверить значение свойства GraphicsDeviceCapabilities.DeviceCapabilities.SupportsHardwareTransformAndLight. Если оно равно true, приложение может создать графическое устройство с использованием флага CreateOptions.HardwareVertexProcessing, в противном случае должен использоваться флаг CreateOptions.SoftwareVertexProcessing. XNA Framework предоставляет разработчику два способа получения доступа к экземпляру объекта GraphicsDeviceCapabilities. Наиболее простым из них является использование свойства GraphicsDeviceCapabilities экземпляра класса графического устройства: public GraphicsDeviceCapabilities GraphicsDeviceCapabilities { get; }
Не смотря на простоту данный способ обладает существенным недостатком: для получения доступа к свойству GraphicsDeviceCapabilities приложение должно создать графическое устройство. Получается замкнутый круг: чтобы получить информацию, необходимую для создания графического устройства, приложение должно создать это устройство. В принципе, мы можем попробовать написать что-то вроде: // Создаем графическое устройство без аппаратной поддержки вершинных шейдеров. device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, CreateOptions.SoftwareVertexProcessing | CreateOptions.SingleThreaded, presentParams); // Если GPU имеет аппаратные вершинные процессоры if (device.GraphicsDeviceCapabilities.DeviceCapabilities.SupportsHardwareTransformAndLight) { // Уничтожаем устройство device.Dispose(); // Снова создаем устройство, но уже с аппаратной поддержкой вершинных шейдеров device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle,
CreateOptions.HardwareVertexProcessing | CreateOptions.SingleThreaded, presentParams); }
Хотя данная технология и работает, всѐ это напоминает поездку из Киева в Харьков через Жмеринку. Поэтому разработчики XNA Framework предусмотрели альтернативный способ получения экземпляра класса GraphicsDeviceCapabilities без создания графического устройства. Как вы знаете, конструктор класса GraphicsDevice принимает в качестве первого параметра экземпляр класса GraphicsAdapter, описывающий используемую видеокарту. Так вот, заботливые разработчики XNA Framework снабдили этот класс методом GetCapabilities, возвращающем экземпляр класса GraphicsDeviceCapabilities, соответствующий этому устройству: public GraphicsDeviceCapabilities GetCapabilities(DeviceType deviceType);
где
deviceType – тип устройства, задаваемый с использованием перечислимого типа DeviceType (таблица 1.4).
Зачем нужен параметр deviceType? Дело в том, что метод GetCapabilities не может предугадать, какой тип устройства вы собираетесь создать (DeviceType.Hardware, DeviceType.Reference или DeviceType.NullReference), в то время как все эти типы устройств имеют совершенно разные характеристики. Соответственно, при помощи параметра deviceType вы указываете методу GetCapabilities, какое значение вы планируете передать параметру deviceType конструктора класса графического устройства (GraphicsDevice). Таким образом, проверку наличия аппаратных вершинных процессоров можно организовать с использованием следующего фрагмента кода: GraphicsDeviceCapabilities caps = GraphicsAdapter.DefaultAdapter.GetCapabilities(DeviceType.Hardware); CreateOptions options = CreateOptions.SingleThreaded; if (caps.DeviceCapabilities.SupportsHardwareTransformAndLight) options |= CreateOptions.HardwareVertexProcessing; else options |= CreateOptions.SoftwareVertexProcessing; device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, options, presentParams);
Полная версия приложения находится на CD с книгой в каталоге Examples\Ch02\Ex02.
2.4.2. Управление размером точек. Точка, визуализируемая нашим приложением (Ex02), имеет достаточно небольшой размер, в результате чего еѐ достаточно тяжело различить на поверхности формы. К счастью, этот недочет можно достаточно легко исправить. В классе GraphivsDevice имеется свойство RenderState, позволяющее управлять различными параметрами визуализации примитивов: public RenderState RenderState {get; }
Это свойство возвращает экземпляр класса RenderState, содержащий множество свойств, влияющих на процесс визуализации. В частности, свойство RenderState.PointSize отвечает за размер точек: // По умолчанию значение этого свойства равно 1.0f float PointSize { get; set; }
Так, присвоив свойству PointSize значение 10, мы увеличите размер визуализируемых точек до 10x10 пикселей (рисунок 2.9): // Фрагмент обработчика события Paint формы device.RenderState.PointSize = 10.0f;
Рисунок 2.9 Точка размером 10x10 пикселей.
Однако мы не можем просто так взять и присвоить свойству GraphicsDevice.RenderState.PointSize произвольное значение. Ведь никто не может гарантировать, что ваши программы будут запускаться исключительно на тех видеокартах, которые умеют работать с большими точками размером 10x10. Следовательно, необходимо предусмотреть поведение приложения в ситуации, когда видеокарта не удовлетворяет минимальным требованиям к размеру точек: наиболее логичное действие приложения в подобной ситуации – выдача соответствующего сообщение об ошибке с последующим завершением работы. В разделе 2.4.1 упоминалось, что в XNA Framework имеется класс GraphicsDeviceCapabilities с информацией о возможностях графического устройства. В частности, свойство PointSize содержит максимальный размер точки в пикселях, поддерживаемый указанным графическим устройством: public float MaxPointSize { get; }
Дополнительная информация Для быстрого получения информации о возможностях текущей видеокарты я обычно пользуюсь тестовым пакетом D3D RightMark, инсталлятор которого находится на CD с книгой в каталоге RightMark D3D. Достаточно запустить D3D RightMark, щелкнуть левой кнопкой мыши на узле D3D RightMark | Direct3D 9.0 Information (вкладка Available Tests) и в правой части экрана появится древовидный список возможностей видеокарты. В частности на рисунке 2.10 видно, что видеокарта ATI Radeon 9800 XT может визуализировать точки размером не более 256x256 пикселей. К сожалению D3D RightMark имеет одну нехорошую особенность – он всегда загружает процессор на 100%. Не забывайте закрывать D3D RightMark, когда он вам больше не нужен; в противном случае вы рискуете столкнуться с резким падением производительности других приложений.
Рисунок 2.10. Тестовый пакет D3D RightMark
Думаю, вам не составит труда написать код, проверяющий аппаратную поддержку видеокартой точек размером 10x10 пикселей (Ex02). Для этого достаточно вставить в обработчик события Load после создания графического устройства командой new GraphicsDevice следующий код: // Если устройство не поддерживает точки размером 10x10 пикселей if (device.GraphicsDeviceCapabilities.MaxPointSize < 10) { // Выводим сообщение об ошибке MessageBox.Show("Устройство не поддерживает точки размером 10 пикселей", "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); // Устанавливаем флаг завершения работы приложения closing = true; // Задаем обработчик события Idle, выполняющий закрытие формы (вызов метода Close внутри // обработчика Load может привести к утечке ресурсов) Application.Idle += new EventHandler(Application_Idle); // Выходим из обработчика события Load
return; }
В таблице 2.6 приведены значения свойства MaxPointSize для некоторых графических процессоров с аппаратной поддержкой пиксельных шейдеров. Обратите внимание, что все они поддерживают точки размером не менее 64-х пикселей. Следовательно, так как XNA Framework требует от видеокарты обязательной поддержки пиксельных шейдеров, приложению, использующему XNA Framework вовсе не обязательно проверять поддержку пикселей размером менее 64-х пикселей. Это обстоятельство позволит нам несколько сократить код некоторых примеров без ущерба надежности. Таблица 2.6. Максимальные размеры точек для некоторых GPU. GPU
Максимальный размер точки (в пикселях)
NV20 (NVIDIA GeForce3)
64
NV25 (NVIDIA GeForce4)
8192
NV3x (NVIDIA GeForce FX)
8192
R2xx – R5xx (ATI Radeon)
256
GMA 900 (Intel 915G)
256
GMA 950 (Intel 945G)
256
2.4.3. Визуализация набора точек. В этом разделе мы доработаем нашу программу, включив в неѐ возможность добавления новых точек путем простых щелчков левой кнопкой мыши на поверхности формы. Для этого мы добавим в программу обработчик события MouseDown, который при нажатии левой кнопки мыши будет добавлять в массив вершин новые точки с координатами курсора мыши. Ну и, разумеется, немного подправим обработчик события Paint. Основные фрагменты кода полученного приложения приведены в листинге 2.8 (Ex04). Листинг 2.8. public partial class MainForm : Form { ... // Массив вершин VertexPositionColor[] vertices = null; // Количество вершин int pointCount = 0; ... private void MainForm_Load(object sender, EventArgs e) { ... // Вычисляем максимальное количество вершин, которые видеокарта может визуализировать за один // вызов метода DrawUserPrimitives maxVertexCount = Math.Min(device.GraphicsDeviceCapabilities.MaxPrimitiveCount, device.GraphicsDeviceCapabilities.MaxVertexIndex); // Создаем массив вершин, рассчитанный на хранение 16-ти вершин vertices = new VertexTransformedPositionColor[16]; } private void MainForm_Paint(object sender, PaintEventArgs e) { ... // Очищаем экран device.Clear(XnaGraphics.Color.CornflowerBlue); // Если количество точек больше нуля (метод DrawUserPrimitives некорректно работает с // массивами нулевого размера) if (pointCount > 0)
{ device.VertexDeclaration = decl; device.RenderState.PointSize = 10.0f; // Рисуем набор точек effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.PointList, vertices, 0, pointCount); pass.End(); } effect.End(); } device.Present(); ... } private void MainForm_MouseDown(object sender, MouseEventArgs e) { // Если нажата левая кнопка мыши if (e.Button == MouseButtons.Left) { // Если количество вершин достигло предельной величины if (pointCount == maxVertexCount) { // Выводим предупреждение и выходим из обработчика события MessageBox.Show(String.Format("Количество точек достигло максимального”+ “значения для данного GPU: {0}.", maxVertexCount), "Внимание", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // Если массив вершин полностью заполнен if (pointCount == vertices.Length) { // Вычисляем новый размер массива (удваиваем размер массива) int newSize = vertices.Length * 2; // Размер массива не может превышать предельного значения if (newSize > maxVertexCount) newSize = maxVertexCount; // Создаем новый массив увеличенного размера VertexPositionColor[] newVertices = new VertexPositionColor[newSize]; // Копируем в него первоначальный массив vertices.CopyTo(newVertices, 0); // Присваиваем полю vertices ссылку на новый массив vertices = newVertices; } // Заносим в массив информацию о новой точки, формируемой на основе текущих координат // указателя мыши. Для перевода координат указателя мыши в логическую систему координат XNA // Framework используется “самодельный” метод MouseToLogicalCoords. vertices[pointCount] = new VertexPositionColor( Helper.MouseToLogicalCoords(e.Location, ClientSize), XnaGraphics.Color.Aqua); // Увеличиваем счетчик количества точек
pointCount++; // Перерисовываем экран Invalidate(); } } }
Пройдемся по наиболее интересным особенностям приложения. Как известно, массивы .NET Framework не умеют изменять свой размер, поэтому при добавлении элемента в массив необходимо создать массив увеличенного размера, скопировать в него первоначальный массив и занести в последний элемент массива информацию о новой точке. Однако это не совсем оптимальный вариант, так как каждое добавление новой вершины сопряжено с довольно затратными операциями копирования массива, включая, возможную сборку мусора. В нашей программе используется более агрессивный подход: при каждом увеличении размера массива его размер увеличивается с неким запасом (размер массива увеличивается не на один элемент, а сразу удваивается), чтобы уменьшить вероятность повторного выделения памяти при добавлении следующих точек и уменьшить частоту запуска сборщика мусора. П р им еч а н ие На первый взгляд, может показаться, что информацию о вершинах было бы рациональнее хранить в Geneticколлекции list. Однако у подобного подхода есть один неочевидный недостаток. Дело в том, что метод DrawUserPrimitives умеет работать исключительно с классическими массивами System.Array, в результате чего нам придется постоянно преобразовывать список в массив посредством метода ToArray(), неявно создающим новый массив и копирующим в него содержимое списка. Таким образом, использование класса list снизит производительность приложения за счет неявного копирования информации из списка в массив, и, что ещѐ хуже, повысит интенсивность вызовов сборщика мусора для удаления предыдущих массивов.
Другой очень полезный приѐм, используемый в программе – вывод всех точек одним вызовом метода GraphicsDevice.DrawUserPrimitives. Дело в том, что метод GraphicsDevice.DrawUserPrimitives тратит относительно много времени центрального процессора на подготовку графического ускорителя к визуализации примитивов, при этом собственно процесс визуализации выполняется графическим ускорителем и практически не требует вмешательства со стороны центрального процессора. Таким образом, рисуя все точки за один присест, мы значительно снижаем нагрузку на центральный процессор, распараллеливая работу между CPU и GPU. Однако метод DrawUserPrimitives имеет ограничения на максимальное количество примитивов, которые можно визуализировать за один вызов этого метода. Количество вершин, которые можно вывести за один присест, тоже далеко не бесконечно. Информация о возможностях текущей видеокарты по визуализации примитивов хранится в двух свойствах класса GraphicsDeviceCapabilities: // Максимальное количество примитивов, которые можно визуализировать за один присест public int MaxPrimitiveCount { get; } // Максимальное количество вершин, которые можно визуализировать за один присест. public int MaxVertexIndex { get; }
В таблицах 2.7 и 2.8 приведены значения этого свойства для наиболее распространенных моделей видеокарт. Например, интегрированная видеокарта Intel GMA 900 могут визуализировать не более 65535 примитивов и не более 65534 вершины. При запуске приложения на данной видеокарте оно будет упираться в максимальное количество вершин (65534). А вот на видеокартах корпорации ATI наше приложение будет упираться в максимальное количество визуализируемых примитивов. Таким образом, при оценке максимального количества точек, которые приложение может вывести на экран, необходимо учитывать как значение свойства MaxPrimitiveCount, так и MaxVertexIndex: maxVertexCount = Math.Min(device.GraphicsDeviceCapabilities.MaxVertexIndex, device.GraphicsDeviceCapabilities.MaxPrimitiveCount)
Таблица 2.7. Значение свойства MaxPrimitiveCount для некоторых GPU GPU
Значение
NVIDIA NV2x - NV3x
1.048.575
ATI R2xx - R5xx
1.048.575
Intel GMA 9xx
65.535
65.535 – 1.048.57535
Intel GMA 3000
Таблица 2.8. Значение свойства MaxVertexIndex для некоторых GPU GPU
Значение
ATI R2xx - R5xx
16.777.215
NVIDIA NV2x - NV3x
1.048.575
Intel GMA 9xx
65.534
Intel GMA 3000
65.534 – 16.777.21536
В ни ма н ие ! Если количество визуализируемых примитивов превысит допустимый лимит, на некоторых компьютерах могут начать происходить странные вещи вплоть до полного краха системы и “синего экрана смерти” (blue screen of death). Эта особенность является обратной стороной медали высокой производительности XNA Framework – любое некорректно написанной XNA-приложение теоретически может нарушить работу всей системы.
И так, теоретически приложение вполне может столкнуться с видеокартой, способной выводить не более 65534 примитивов за один присест. Много это и ли мало? Например, если пользователь будет каждую секунду добавлять на экран по точке, то через 18 часов он достигнет лимита для Intel GMA 900. Иными словами, это довольно внушительное значение для нашего приложения, но вполне достижимое. Поэтому в приложение на всякий случай встроена проверка: при достижении предала на количество визуализируемых примитивов, точки просто перестают добавляться в массив. Как говорится, дешево и сердито 37. Так же стоит обратить внимание на проверку размера массива на неравенство нулю перед тем, как вывести его на экран. Дело в том, что метод DrawUserPrimitives при попытке визуализации массива генерирует исключение System.IndexOutOfRangeException. Хотя подобное поведение метода нельзя назвать безупречным, эту особенность приходится учитывать. В заключении следует обратить внимание на преобразование координат указателя мыши из системы координат клиентской области окна в логическую систему XNA Framework, в которой координаты компонентов вершин лежат в диапазоне от -1 .. +1. Кроме того, следует учитывать, что в Windows положительное направление оси Y направленно вниз, а в XNA Framework – вверх. Так подобные преобразования будут довольно часто применяться в наших приложениях, они были вынесены в отдельный класс Helper, расположенный в файле Helper.cs (листинг 2.9). В дальнейшем мы продолжим размещать в этом классе различные вспомогательные методы, облегчающие работу с XNA Framework. Листинг 2.9. using System; using System.Collections.Generic; using System.Text; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; namespace GSP.XNA { class Helper { // Принимает в качестве параметров координаты указателя мыши и размер клиентской области окна. Возвращает координаты указателя мыши в оконной системе координат. public static Vector3 MouseToLogicalCoords(System.Drawing.Point location, System.Drawing.Size clientSize) 35
Зависит от версии драйвера. Зависит от версии драйвера. 37 Более практичным подходом является автоматическая генерация дополнительных массивов по мере достижения размера предыдущего массива значения MaxPrimitiveCount. Однако эта функциональность заметно усложнит приложение, а еѐ полезность в данном случае весьма сомнительна. 36
{ Vector3 v; // Приводим координаты указателя мыши к диапазону [-1, +1]. Для предотвращения деления на 0 // используется метод Math.Max, не дающий знаменателю дроби стать меньше 1. v.X = (float)location.X / (float)Math.Max(clientSize.Width - 1, 1) * 2.0f - 1.0f; v.Y = 1.0f - (float)location.Y / (float)Math.Max(clientSize.Height - 1, 1)*2.0f; v.Z = 0.0f; return v; } } }
2.4.4. Управление цветом точек средствами HLSL. В этом, заключительно разделе, посвященном точкам, мы добавим в наше приложение возможность визуализации разноцветных пикселей. В принципе, это довольно тривиальная операция, если бы не одно но: в настоящее время наше приложение визуализирует точки исключительно фиксированного цвета морской волны (aqua), который жестко задан в файле эффекта (в нашем случае это SimpleEffect.fx) и не может быть изменен C#-приложением. К примеру, если вы исправите код vertices[pointCount] = new VertexPositionColor( Helper.MouseToLogicalCoords(e.Location, ClientSize), XnaGraphics.Color.Aqua);
на vertices[pointCount] = new VertexPositionColor( Helper.MouseToLogicalCoords(e.Location, ClientSize), XnaGraphics.Color.Red);
цвет точек не изменится, так как вершинные и пиксельные шейдеры игнорируют данное значение. Следовательно в первую очередь нам необходимо модифицировать fx-файл приложения, научив эффект адекватно реагировать на информацию о цвете вершины.
Входные и выходные параметры функций языка HLSL Начнем модификацию эффекта с вершинного шейдера. Теперь на вход шейдера будут подаются два параметра: координаты вершины (iPos) и цвет вершины (iColor). Результаты выполнения шейдера – однородные координаты вершины (oPos) и цвет вершины (oColor). Для указания компилятору связи входного параметра iColor с цветом вершины используется семантика COLOR (листинг 2.10). Семантика COLOR выходного параметра oColor указывает компилятору на то, что в этом параметре хранится результирующий цвет вершины. Листинг 2.10. void MainVS(in float3 iPos:POSITION, in float4 iColor:COLOR, out float4 oColor:COLOR) { oPos = float4(iPos, 1);
out float4 oPos:POSITION,
// Просто копируем параметр Color без изменения. oColor = iColor; }
Обратите внимание на использование новых ключевых слов: in и out. Ключевое слово in используется для указания входных параметров, передающихся по значению. Ключевое слово out указывает на то, что параметр является возвращаемым: по завершению работы функции значение out-параметра копируется в вызывающий метод. Если параметр является одновременно и входным и выходным, то для указания этого факта используется ключевое слово inout. Например, мы можем объединить параметры iColor и oColor в один параметр Color, что позволит немного упростить код шейдера (листинг 2.11). Листинг 2.11. // Цвет вершины (параметр color) проходит через вершинный шейдер без изменений void MainVS(in float3 iPos:POSITION, inout float4 color:COLOR, out float4 oPos:POSITION)
{ oPos = float4(iPos, 1); }
Если не указан тип параметра функции (in, out или inout), HLSL делает этот параметр входящим (in). Соответственно, ключевое слово in указывать не обязательно. Кстати, мы активно использовали эту возможность в прошлом разделе.
Структуры Как известно, передача в функцию большого количества параметров делает код трудночитаемым 38, поэтому параметры шейдера обычно группируют в структуры входных и выходных параметров. Объявление структуры в HLSL аналогично языку C (листинг 2.12). Листинг 2.12. // Объявляем структуру входных данных шейдера. Обратите внимание на возможность назначения // каждому полю структуры семантики struct VertexInput { float3 pos : POSITION; float4 color : COLOR; }; // Объявляем структуру выходных данных шейдера struct VertexOutput { float4 pos : POSITION; float4 color : COLOR; }; void MainVS(in VertexInput input, out VertexOutput output) { output.pos = float4(input.pos, 1.0f); output.color = input.color; }
Как видно, использование структур делает код значительно более понятным: для определения формата входных данных вершинного шейдера, достаточно лишь беглого взгляда на определение структуры VertexInput. После этой модификации наш шейдер MainVS возвращает в качестве результата лишь один параметр (output). Следовательно, процедуру MainVS можно заменить функцией, что сделает код программы ещѐ более интуитивно понятным (листинг 2.13). Листинг 2.13. VertexOutput MainVS(VertexInput input) { // Создаѐм структуру output VertexOutput output; output.pos = float4(input.pos, 1.0f); output.color = input.color; // Возвращаем результаты работы шейдера return output; }
Пиксельный шейдер. После обработки вершинным процессором вершины объединяются в примитивы, которые разбиваются на отдельные пиксели (то есть растеризуются). При этом параметры вершины, рассчитанные вершинным шейдером, интерполируются вдоль поверхности примитива. В нашем случае, вдоль поверхности примитива интерполируется цвет вершины. Иными словами, каждому пикселю примитива ставится в соответствие 38
Сложный шейдер может принимать до нескольких десятков различных параметров.
интерполированный цвет (при визуализации точек вдоль поверхности точки интерполируется константный цвет). Наш пиксельный шейдер будет просто принимать интерполированный цвет и выводить его на экран (листинг 2.14). Листинг 2.14. float4 MainPS(float4 color:COLOR):COLOR { return color; }
Для привязки входных данных пиксельного шейдера к интерполированным выходным данным из вершинного шейдера используется семантика COLOR. Хочу обратить ваше внимание на то, что семантики выходных данных вершинного шейдера и входных данных пиксельного шейдера нечего не говорят о смысле этих данных39. Главное предназначение этих семантик – связь между выходными параметрами вершинного шейдера и входными параметрами пиксельным шейдера. Например, замена семантики COLOR на TEXCOORD некоим образом не повлияет на работу приложения (листинг 2.14). Главное, чтобы выходные параметры вершинного шейдера и входные параметры пиксельного шейдера использовали одинаковые семантики. П р им еч а н ие Так как профили семейства ps_1_x не позволяют использовать четырех компонентные текстурные координаты, нам пришлось применить профиль ps_2_0. Использование текстурных координат будет рассмотрено в разделе 2.6. Листинг 2.14. struct VertexInput { float3 pos : POSITION; float4 color : COLOR; }; struct VertexOutput { float4 pos : POSITION; // Рассчитанный цвет вершины, передаѐтся как текстурные координаты float4 color : TEXCOORD; };
VertexOutput MainVS(VertexInput input) { VertexOutput output; output.pos = float4(input.pos, 1.0f); output.color = input.color; return output; } // Пиксельный шейдер получает входные параметры из интерполированных текстурных координат float4 MainPS(float4 color:TEXCOORD):COLOR { return color; } technique Fill { pass p0 39
В профилях до ps_3_0 семантики иногда всѐ же могут оказывать незначительное влияние на работу шейдера. Эта тема подробно будет рассмотрена в разделе 4.x.
{ VertexShader = compile vs_2_0 MainVS(); PixelShader = compile ps_2_0 MainPS(); } }
Доработка C#-приложения С кодом эффекта мы вполне разобрались и, следовательно, можем приступать к модификации C#-кода нашего приложения: теперь при каждом щелчке левой кнопкой мыши на форму будут добавляться разноцветные точки случайного цвета. Для этого достаточно лишь немного подправить обработчик события MouseDown (листинг 2.15). Листинг 2.15. private void MainForm_MouseDown(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { // Если достигли предельного количества точек, выходим if (pointCount == maxVertexCount) { MessageBox.Show(String.Format("Количество точек достигло максимального значения”+ “ для данного GPU: {0}.", maxVertexCount), "Внимание", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // При необходимости удваиваем размер массива. if (pointCount == vertices.Length) { int newSize = vertices.Length * 2; if (newSize > maxVertexCount) newSize = maxVertexCount; VertexPositionColor[] newVertices = new VertexPositionColor[newSize]; vertices.CopyTo(newVertices, 0); vertices = newVertices; } XnaGraphics.Color color; double delta; do { // Вычисляем случайные значение компонентов R, G, B цвета точки byte[] bytes = new byte[3]; rnd.NextBytes(bytes); // Формируем цвет color = new XnaGraphics.Color(bytes[0], bytes[1], bytes[2]); // Вычисляем квадрат “расстояния” между рассчитанным случайным цветом и цветом фона формы delta = Math.Pow((color.R - XnaGraphics.Color.CornflowerBlue.R), 2) + Math.Pow((color.G - XnaGraphics.Color.CornflowerBlue.G), 2) + Math.Pow((color.B - XnaGraphics.Color.CornflowerBlue.B), 2); } // Если цвет точки слабо отличается от цвета фона, повторяем вычисления. while(delta < 1000); // Заносим информацию о точке в массив вершин vertices[pointCount] = new VertexPositionColor(Helper.MouseToLogicalCoords(
e.Location, ClientSize), color); pointCount++; } Invalidate(); }
При генерации случайного цвета точки приложение проверяет, не сольѐтся ли полученный цвет с цветом фона. Так как в компьютерной графике цвет задаѐтся яркостью трех компонентов, мы можем трактовать значения этих трех компонентов как координаты цвета в некотором цветовом пространстве (рисунок 2.11). Соответственно, в качестве критерия похожести двух цветов можно использовать расстояние между этими цветами:
r (c1r c2r ) 2 (c1g c2 g ) 2 (c1b c2b ) 2
(2.1)
где
r – расстояние между цветами в цветовом пространстве.
c1r , c1g , c1b – яркости красного, зеленого и синего компонента первого цвета;
c2 r , c2 g , c2b – яркости красного, зеленого и синего компонента второго цвета.
Однако учитывая высокую ресурсоѐмкость операции вычисления квадратного корня, в качестве критерия похожести цветов рациональнее использовать не само расстояние, а его квадрат. Полная версия приложения находится на CD с книгой в каталоге Ch02\Ex05.
R
Color1
Color2
G
B Рисунок 2.11. Цветовое пространство.
Практическое упражнение №2.1 Создайте приложение, рисующее поточечный график функции y=cos(x), где x находится в диапазоне 0°…720° (рисунок 2.12). Если у вас возникнут трудности при выполнении упражнения, посмотрите готовое приложение на CD с книгой (Ch02\Ex06).
Рисунок 2.12. Поточечный график функции y=f(x), визуализированный с использованием двухсот точек.
2.5. Отрезки В XNA Framework имеется два типа отрезков: независимые отрезки (PrimitiveType.LineList) и связанные отрезки (PrimitiveType.LineStrip). При указании независимого типа отрезков метод Device.DrawUserPrimitives рисует набор несвязанных между собой отрезков прямых линий. Первый отрезок рисуется между нулевой и первой вершиной набора вершин, второй отрезок – между второй и третьей, и т.д. (рисунок 2.13). Данный тип примитивов обычно применяется для рисования отдельных отрезков. Связанные отрезки (PrimitiveType.LineStrip) используются для построения ломаной линии, проходящей через вершины. Первый сегмент линии рисуется между нулевой и первой вершиной, второй – между первой и второй вершиной и т.д. (рисунок 2.14). v1
v5 v4
v0 v2
v3 Рисунок 2.13. Независимые отрезки (Direct3D.PrimitiveType.LineList)
v1
v5 v4
v0 v2
v3 Рисунок 2.14. Связанные отрезки (Direct3D.PrimitiveType.LineStrip)
2.5.1. Независимые отрезки (PrimitiveType.LineList). Для демонстрации практического использования примитивов PrimitiveType.LineList мы перепишем пример Ex04. Первая точка отрезка будет задаваться нажатием левой кнопки, а вторая – при отпускании левой кнопки мыши. Таким образом, процесс рисования линии будет аналогичен редактору Paint – пользователь помещает указатель мышь в начало отрезка, зажимает левую кнопку, и ведѐт указатель мыши до конца отрезка, после чего отпускает левую кнопку мыши. В листинге 2.16 приведены основные фрагменты исходного кода полученного приложения (Ex07): Листинг 2.16 public partial class MainForm : Form { ...
// Массив вершин отрезков VertexPositionColor[] vertices = null; // Количество отрезков int lineCount = 0; // Максимальное количество отрезков, которые текущая видеокарта может визуализировать одним // вызовом метода DrawUserPrimitives int maxLineCount; // Флаг, показывающий, находится ли программа в режиме добавления нового отрезка (когда // пользователь уже указал начало отрезка, но ещѐ не отжал левую кнопку мыши) bool AddingLine = false; ... private void MainForm_Load(object sender, EventArgs e) { ... // Определяем максимальное количество отрезков, которое видеокарта может визуализировать за // один вызов метода DrawUserPrimitives maxLineCount = Math.Min(device.GraphicsDeviceCapabilities.MaxPrimitiveCount, device.GraphicsDeviceCapabilities.MaxVertexIndex / 2); // Создаѐм массив, рассчитанный на хранение вершин восьми отрезков vertices = new VertexPositionColor[16]; } private void MainForm_Paint(object sender, PaintEventArgs e) { ... // Очищаем экран device.Clear(XnaGraphics.Color.CornflowerBlue); // Если количество отрезков больше нуля if (lineCount > 0) { device.VertexDeclaration = decl; // Визуализируем отрезки effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.LineList, vertices, 0, lineCount); pass.End(); } effect.End(); } device.Present(); ... } ... private void MainForm_MouseDown(object sender, MouseEventArgs e) { // Если нажата левая кнопка мыши if (e.Button == MouseButtons.Left) { // Если количество линий достигло предельно возможной величины, нечего не делаем if (lineCount == maxLineCount)
{ MessageBox.Show(String.Format("Количество отрезков достигло максимального” + “значения для данного GPU: {0}.", maxLineCount), "Внимание", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } // Переходим в режим добавления отрезка AddingLine = true; // Если размер массива вершин не достаточен вставки нового отрезка, то создаем массив // удвоенного размера и копируем в него содержимое старого массива. if (lineCount * 2 >= vertices.Length) { int newLineCount = lineCount * 2; // Размер массива не должен превышать предельно лимит текущей видеокарты if (newLineCount > maxLineCount) newLineCount = maxLineCount; VertexPositionColor[] newVertices = new VertexPositionColor[newLineCount*2]; vertices.CopyTo(newVertices, 0); vertices = newVertices; } // Заносим в массив вершин координаты начала и конца нового отрезка. Для перевода координат // указателя мыши к диапазону [-1, +1] используется метод MouseToLogicalCoords, созданный // нами в разделе 2.4.3. vertices[lineCount * 2] = new VertexPositionColor(Helper.MouseToLogicalCoords(e.Location, ClientSize), XnaGraphics.Color.Aqua); vertices[lineCount * 2 + 1] = vertices[lineCount * 2]; // Увеличиваем счетчик количества отрезков на 1 lineCount++; // Перерисовываем форму Invalidate(); } } private void MainForm_MouseMove(object sender, MouseEventArgs e) { // Если программа находится в режиме добавления нового отрезка if (AddingLine == true) { // Обновляем координаты конца отрезка vertices[lineCount * 2 - 1].Position = Helper.MouseToLogicalCoords(e.Location, ClientSize); // Перерисовываем экран Invalidate(); } } private void MainForm_MouseUp(object sender, MouseEventArgs e) { // Если была отжата левая кнопка мыши if (e.Button==MouseButtons.Left) // Выходим из режима добавления нового отрезка AddingLine = false; } }
Небольшого внимания заслуживает код, вычисляющий максимальное количество линий, которое может визуализировать видеокарта за один вызов метода DrawUserPrimitives. Как вы знаете из раздела 2.4.3, значение максимального количества примитивов, которые может визуализировать видеокарта за один присест, определяется свойствами GraphicsDeviceCapabilities.MaxPrimitiveCount и GraphicsDeviceCapabilities.MaxVertexIndex. Но так как каждый примитив типа PrimitiveType.LineList содержит две вершины, при оценке максимального количества отрезков, которые может визуализировать видеокарта за один присест, приложение должно поделить значение GraphicsDeviceCapabilities.MaxVertexIndex на 2. Чтобы сделать работу с программой более комфортной, мы встроим в неѐ возможность отмены изменений при помощи комбинации клавиш Ctrl+Z, что позволит пользователю легко откатываться назад после ошибочно нарисованных отрезков и т.д. Код обработчика, выполняющего откат изменений, приведѐн в листинге 2.17. После такой доработки нашу программу вполне можно будет использовать как простенький графический редактор (рисунок 2.15). Листинг 2.17. private void MainForm_KeyDown(object sender, KeyEventArgs e) { if ((e.KeyCode==Keys.Z) && (e.Control==true)) if (AddingLine == false) { if (lineCount > 0) lineCount--; Invalidate(); } }
Рисунок 2.15. Изображение, нарисованное при помощи нашего самодельного графического редактора (Ex07)
2.5.2. Связанные отрезки (PrimitiveType.LineStrip). Перейдѐм к следующему типу примитивов – PrimitiveType.LineStrip. Как говорилось выше, этот тип примитивов применяется для рисования ломаных линий, которые часто используются при построении контуров различных поверхностей или графиков функций. Чтобы опробовать примитивы типа PrimitiveType.LineStrip на практике, мы напишем приложение, рисующее в центре формы окружность радиусом 0.8 единиц (Ex08). Окружность будет нарисована с использованием ломаной линии, содержащей тридцать два сегмента. Каждая вершина ломанной будет иметь свой цвет, благодаря чему окружность будет переливаться различными цветами (рисунок 2.16). Для вычисления координат вершин окружности мы воспользуемся простой формулой из школьного курса аналитической геометрии:
0..360 x x0 r sin( ) y y0 r cos( ) где
x и y – координаты текущей вершины окружности
x0 и y0 – координаты центра окружности
– угол, пробегающий с некоторым шагом значения от 0° до 360°.
r – радиус окружности
Наиболее важные фрагменты приложения приведѐны в листинге 2.18.
Рисунок 2.16. Окружность, нарисованная с использованием примитивов Direct3D.PrimitiveType.LineStrip.
Листинг 2.18. public partial class MainForm : Form { GraphicsDevice device = null; PresentParameters presentParams; VertexDeclaration decl; VertexPositionColor[] vertices = null; // Количество сегментов в ломанной линии, аппроксимирующей окружность. const int LineStripCount = 32; ... private void MainForm_Load(object sender, EventArgs e) { ... decl = new VertexDeclaration(device, VertexPositionColor.VertexElements); // Создаѐм графический буфер, для хранения вершин окружности vertices = new VertexPositionColor[LineStripCount + 1]; } private void MainForm_Paint(object sender, PaintEventArgs e) { ... // Очищаем экран device.Clear(XnaGraphics.Color.CornflowerBlue); device.VertexDeclaration = decl;
(2.2)
// Перебираем все вершины for (int i = 0; i scintillas; // Массив вершин для визуализации искр VertexPositionColor[] scintillasVertices = null; // Количество вершин, хранящихся в массиве int scintillasVertexСount = 0; // Генератор случайных чисел, используемый классами Firework и Scintilla public static Random rnd = new Random(); // Конструктор public Firework(IntPtr hWnd, float scintillaSize, float scintillaInterval) { this.hWnd = hWnd; // Запоминаем пользовательские настройки this.scintillaSize = scintillaSize; this.maxScintillaCount = maxScintillaCount; ... // Создаем список вершин scintillas = new List<Scintilla>(16); // Создаем массив вершин scintillasVertices = new VertexPositionColor[16]; ... } // Обновляет сцену public void Update() { double currentTime = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency; // Максимальный временной интервал не должен превышать maxDelta if (currentTime - lastTime > maxDelta) lastTime = currentTime - maxDelta; // Количество дискретных шагов timeStep, которые необходимо выполнить int stepCount = (int)Math.Floor((currentTime - lastTime) / timeStep); // Интервал между двумя вызовами метода Update, с учетом дискретности времени float delta = stepCount * timeStep; // Поворачиваем диск diskAngle += diskSpeed * delta; // Корректируем положение вершин диска ...
// Моделируем движение искр с дискретным шагом времени for (int i = 0; i < stepCount; i++) { lastTime += timeStep; // Счетчик количества не потухших искр scintillasVertexСount = 0; // Количество новых искр, которые могут появиться на данном шаге int scintillaCount = rnd.Next(maxScintillaCount + 1); // Перебираем все искры for (int j = 0; j < scintillas.Count; j++) { // Обновляем состояние текущей искры scintillas[j].Update(delta); // Если искра не потухла if (scintillas[j].time > 0) { // Увеличиваем счетчик не потухших искр scintillasVertexСount++; } else // Если искра является потухшей { // Пока не исчерпан лимит новых искр while (scintillaCount > 0) { // Пробуем добавить новую искру, поэтому уменьшаем счетчик искр scintillaCount--; // Генерируем новую искру с вероятностью scintillaProbability if ((Firework.rnd.NextDouble() < scintillaProbability)) { // Инициализируем текущую искру scintillas[j].Init(diskAngle); // Увеличиваем счетчик искр scintillasVertexСount++; break; } } } } // Если необходимо создать еще несколько новых искр while (scintillaCount > 0) { scintillaCount--; if ((Firework.rnd.NextDouble() < scintillaProbability)) { // Добавляем в список информацию о новой искре scintillas.Add(new Scintilla()); scintillas[scintillas.Count - 1].Init(diskAngle); scintillasVertexСount++; } } } // Если число искр превышает размер массива вершин if (scintillasVertexСount > scintillasVertices.Length) { // Удваиваем размер массива. Если размер удвоенного массива недостаточен, используем в
// качестве размера массива текущее количество вершин (на всякий случай перестраховываемся) scintillasVertices = new VertexPositionColor[Math.Max(scintillasVertexСount, scintillasVertices.Length * 2)]; } // Копируем информацию о искрах в массив вершин int k = 0; for (int i = 0; i < scintillas.Count; i++) // Учитывает только не потухшие искры if (scintillas[i].time > 0) { scintillasVertices[k] = scintillas[i].Vertex; k++; } } // Визуализация сцены public void Paint() { ... device.RenderState.CullMode = CullMode.None; // Задаем режим смешения пикселей для моделирования полупрозрачности device.RenderState.AlphaBlendEnable = true; device.RenderState.BlendFunction = BlendFunction.Add; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.InverseSourceAlpha; // Задаем размер точек (искр) device.RenderState.PointSize = scintillaSize; ... // Визуализируем массив искр if (scintillasVertexСount > 0) { device.DrawUserPrimitives(PrimitiveType.PointList, scintillasVertices, 0, scintillasVertexСount); } // Визуализируем вращающийся диск device.DrawUserPrimitives(PrimitiveType.TriangleFan, diskVertices, 0, diskVertices.Length - 2); ... }
Обработчик события Load формы также нуждается в косметической правке: private void FullscreenForm_Load(object sender, EventArgs e) { ... // Задаем размеры искр и максимальное количество искр, генерируемое каждые 5 миллисекунд firework = new Firework(Handle, 3.0f, 10f); ... }
Проект с исходным кодом примера находится на CD с книгой в каталоге Examples\Ch04\Ex02.
4.3. Преобразование приложения в хранитель экрана. Ну что ж, настало время подумать о преобразовании приложения в хранитель экрана. Как известно, любой хранитель экран должен автоматически завершать работу приложения при активности пользователя.
Начнем с клавиатуры – нажатие любой клавиши клавиатуры должно немедленно завершать работу приложения (листинг 4.6). Листинг 4.6. private void FullscreenForm_KeyDown(object sender, KeyEventArgs e) { Close(); }
Аналогичным образом приложение должно завершать работу при нажатии кнопки мыши или перемещении курсора мыши. Но здесь есть одна тонкость. Дело в том, что курсор мыши с высокой чувствительностью может реагировать даже на незначительные воздействия вроде микроколебаний стола из-за проезжающего за окном поезда. Поэтому во избежание непреднамеренных прерываний хранителя экрана мы будет завершать работу приложения только после того, как курсор отодвинется от первоначального положения в момент активации хранителя экрана на расстояние порядка 10 пикселей (листинг 4.7). Листинг 4.7. public partial class FullscreenForm : Form { // Флаг, устанавливаемый в true после первого вызова обработчика события MouseMove bool isMouseActive = false; // Координаты мыши при первом вызове обработчика события MouseMove System.Drawing.Point mouseLocation; ... // Обработчик события MouseDown, завершающий работу приложения при нажатии кнопки мыши private void FullscreenForm_MouseDown(object sender, MouseEventArgs e) { Close(); } // Обработчик события MouseMove private void FullscreenForm_MouseMove(object sender, MouseEventArgs e) { // Обработчик события MouseMove запускается впервые if (!isMouseActive) { isMouseActive = true; // Запоминаем текущие координаты мыши mouseLocation = e.Location; } else { // Если курсор мыши переместился вдоль оси X или Y от своего первоначального положения // больше, чем на 10 единиц if ((Math.Abs(e.Location.X - mouseLocation.X) > 10) || (Math.Abs(e.Location.Y - mouseLocation.Y) > 10)) { // Завершаем работу приложения Close(); } } } }
В процессе работы хранителя экрана некоторое приложение может вывести на экран диалоговое окно с важной информацией (например, Internet Explorer по окончанию загрузки файла). При этом окно хранителя экран теряет фокус, который переходит к новому диалоговому окну. Хранитель экрана, поверх которого
отображается диалоговое окно, будет выглядеть, мягко говоря, несколько странно, поэтому в качестве одного из критериев завершения работы хранителя экрана логично использовать потерю фокуса формой (листинг 4.8). Листинг 4.8. // Обработчик события Deactivate полноэкранной формы хранителя экран, завершающий работу // приложения при потере формой фокуса private void FullscreenForm_Deactivate(object sender, EventArgs e) { Close(); }
Курсор мыши является чужеродным элементом для хранителя экрана, поэтому его необходимо скрыть посредством метода Hide класса Cursor: Листинг 4.9. private void FullscreenForm_Load(object sender, EventArgs e) { Cursor.Hide(); ... }
Следующее отличие хранителя экрана от обычного приложения состоит в том, что он должен активироваться только при запуске приложения с ключом /s. Соответственно, мы должны добавить в метод Main статического класса Program анализ параметров командной строки (листинг 4.10). П р им еч а н ие Чтобы Visual Studio всегда запускала приложение с ключом /s, укажите этот параметр в поле Command line arguments вкладки Debug свойств проекта. Листинг 4.10. static class Program { [STAThread] static void Main() { // Получаем массив параметров командной строки string[] args = Environment.GetCommandLineArgs(); // Если первый параметр равен "/S" if ((args.Length == 2) && (args[1].ToUpper() == "/S")) { // Отображаем форму приложения с хранителем экрана Application.Run(new FullscreenForm()); return; } // Если параметр не является "/S", нечего не делаем return; } }
В заключении необходимо присвоить файлу хранителя экрана расширение .scr. Переименовывать файл вручную после каждой компиляции приложения довольно утомительно, поэтому мы автоматизируем этот процесс. Откройте в свойствах проекта вкладку Build Events и введите в поле Post-build event command line следующую команду (рисунок 4.3): copy "$(TargetFileName)" "*.scr"
Теперь после каждой компиляции приложения будет вызываться команда copy, создающая копию exeфайла приложения с расширением .scr. Обратите внимание на получение имени exe-файла приложения посредством встроенного макроса $(TargetFileName), благодаря чему команда copy не привязана к фиксированному exe-файлу.
Рисунок 4.3. Вкладка Build Events.
Для проверки работоспособности хранителя экрана откройте каталог с .scr-файлом в файловом менеджере и вызовите его контекстное меню (рисунок 4.4). Как видно, контекстное меню любого исполняемого файла хранителя экрана содержит три пункта: Test – запускает хранитель экрана на выполнение с ключом /s. Configure (Настроить) – открывает окно конфигурации хранителя экрана. Install (Установить) – открывает вкладку Screen Saver диалогового окна Display Properties и выбирает данный хранитель экрана в качестве текущего. Немного проигравшись с нашим хранителем экрана, вы заметите ряд недоделок. Например, при попытке открыть окно конфигурации ровным счетом нечего не происходит, а в окне предварительного просмотра (маленький “дисплейчик”) диалогового окна Display Properties просто выводится изображение по умолчанию. А на компьютере с несколькими мониторами выяснится, что наш хранитель экрана активируется только основном мониторе. Что ж, работы нам предстоит ещѐ много.
Рисунок 4.4. Контекстное меню исполняемого файла хранителя экрана.
4.4. Поддержка нескольких мониторов. В настоящее время поддержка видеокартами двух мониторов уже стала нормой, поэтому любой уважающий себя разработчик должен позаботиться о корректном функционировании приложения на компьютере с несколькими мониторами. В частности, хранитель экрана должен показывать заставку на всех мониторах. Наиболее простое решение – просто отображать на всех мониторах одно и то же изображение. Так как наш хранитель экрана представляет собой форму, развернутую на весь экран, в случае нескольких мониторов мы можем просто создать несколько экземпляров формы – по одному на каждый монитор. Начнем с метода Main. Информация об экранных координатах всех мониторов системы храниться в коллекции AllScreens класса Screen. Соответственно приложение должно просто перебрать элементы этой коллекции и использовать полученную информацию при создании форм (листинг 4.11). Листинг 4.11. static void Main() { string[] args = Environment.GetCommandLineArgs(); if ((args.Length == 2) && (args[1].ToUpper() == "/S")) { // Перебираем все мониторы foreach (Screen screen in Screen.AllScreens) { // Создаем форму размеров во весь монитор FullscreenForm form = new FullscreenForm(screen); // Отображаем форму
form.Show(); } // Запускаем цикл обработки сообщений. Изображение форм будет обновляться посредством // обработчиков события Idle, регистрируемых конструктором формы. Application.Run(); return; } return; }
Конструктор формы, разумеется, так же придется подправить, ведь теперь он будет принимать информацию об экране, на котором будет отображаться форма. Вступать же в силу данный параметр будет после конструирования формы в обработчике события Load (листинг 4.12). Листинг 4.12. Screen screen = null; public FullscreenForm(Screen screen) { this.screen = screen; InitializeComponent(); } private void FullscreenForm_Load(object sender, EventArgs e) { // Форма должна занимать весь экран // Внимание! Свойство Bounds не оказывает влияния, если форма развернута на весь экран // (т.е. когда свойство WindowsState равно Maximized) Bounds = screen.Bounds; ... }
Наконец необходимо определиться с завершением работы. До сих пор все наши приложения содержали лишь одно главное окно, закрытие которого методом Close приводило к завершению работы всего приложения. Теперь же окон несколько, поэтому вызов метода Close закроет лишь единственное окно. Поэтому мы будет завершать работу приложения путем вызова метода Application.Exit. Правда у этого подхода есть один подводный камень – при завершении работы методом Application.Exit не вызываются обработчики события FormClosed. Поэтому код из обработчиков необходимо перенести в обработчики события FormClosing, корректно вызываемых методом Application.Exit. Другой нюанс связан с обработчиком события Deactivate: так мы создаем несколько форм, в процессе создания они будут неминуемо получать-терять фокус (ведь в каждый момент времени только одна форма может иметь фокус). Поэтому во избежание досрочного завершения хранителя экрана в процессе инициализации приложения необходимо игнорировать событие Deactivate. Основные фрагменты обновленных обработчиков событий формы приведены в листинге 4.13. Листинг 4.13. // Ресурсы теперь освобождаются в обработчике события FormClosing private void FullscreenForm_FormClosing(object sender, FormClosingEventArgs e) { if (firework != null) { firework.Dispose(); firework = null; } } private void FullscreenForm_Deactivate(object sender, EventArgs e)
{ // Пока в приложение не запущен цикл обработки сообщений, игнорируем событие Deactivate if (Application.MessageLoop) Application.Exit(); } private void FullscreenForm_MouseDown(object sender, MouseEventArgs e) { // Обратите внимание на завершение приложения посредством метода Application.Exit (вместо // Form.Close) Application.Exit(); } ...
Готовое приложение можно найти на CD книги в каталоге Examples\Ch04\Ex04.
4.5. Диалоговое окно конфигурации хранителя экрана. Настало время подумать об управлении пользовательскими настройками хранителя экрана. Работа нашего хранителя экрана управляется двумя параметрами: размером искр и максимальным числом искр, вылетающих в течение кванта времени, равного 5 миллисекунд. Второй параметр не является интуитивно понятным, ведь рядовому пользователю намного проще регулировать количество искр посредством ползунка “мало – много”. Поэтому мы пойдем на небольшую хитрость: количество искр будет задаваться целочисленным параметром “плотность искр”, лежащим в диапазоне от 0 (минимальное количество искр) до 9 (максимальное количество искр), а число искр, появляющихся каждые 5 секунд, будет рассчитываться уже на основе данного целочисленного параметра. Итак, откройте вкладку настроек приложения (Properties целочисленных параметра (рисунок 4.5):
|
scintillaSize – размер искр. Значение по умолчанию 2 scintillaDensity – плотность искр. Значение по умолчанию 5.
Рисунок 4.5. Вкладка настроек приложения.
Setting) и добавьте в него два
Для начала немного подкорректируйте обработчик события Load полноэкранной формы хранителя экрана, чтобы он брал настройки непосредственно из файла конфигурации приложения (листинг 4.14).
Листинг 4.14. public partial class FullscreenForm : Form { Properties.Settings settings; private void FullscreenForm_Load(object sender, EventArgs e) { ... // Загружаем настройки приложения из файла конфигурации (или настройки по умолчанию при // отсутствии файла) settings = new Properties.Settings(); try { // Количество искр, генерируемых каждые 5 мс, рассчитывается методом “научного тыка” firework = new Firework(Handle, (float)settings.scintillaSize, (settings.scintillaDensity + 1) * 5); } ... }
Теперь создайте новую форму и поместите на неѐ компоненты согласно рисунку 4.6 и таблице 4.2.
Рисунок 4.6. Диалоговое окно параметры.
Таблица 4.2. Свойства формы диалогового окна и элементов управления. Класс
Свойство
Значение
SettingsForm (диалоговое окно)
Name
SettingsForm
Text
Параметры
ShowInTaskbar
false
TopMost
true
MinimizeBox
false
MaximizeBox
false
GroupBox
Text
Искры
Label
Text
Размер:
TrackBar
Name
scintillaSizeTrackBar
Minimum
1
Maximum
4
Label
Text
Плотность:
TrackBar
Name
scintillaDensityTrackBar
Minimum
0
Maximum
9
Name
okButton
Text
Ok
Name
cancelButton
Text
Отмена
Button
Button
Для
автоматической
инициализации
элементов
управления
диалогового
окна
ползунки
scintillaSizeTrackBar и scintillaDensityTrackBar необходимо связать со свойствами scintillaSize и scintillaDensity из конфигурационного файла приложения. Это операция легко выполняется посредством свойства Application Settings | Value ползунков (рисунок 4.7).
Рисунок 4.7. Привязка значения ползунка scintillaSizeTrackBar к свойству scintillaSize файла конфигурации.
Следующий шаг – оживление формы посредством реализации нехитрых обработчиков сообщений (листинг 4.15). Листинг 4.15. public partial class SettingsForm : Form { public SettingsForm() { InitializeComponent(); } // Обработчик нажатия кнопки Ok private void okButton_Click(object sender, EventArgs e) { Properties.Settings settings = new Properties.Settings(); // Задаем новые значения свойств файла конфигурации settings.scintillaSize = scintillaSizeTrackBar.Value;
settings.scintillaDensity = scintillaDensityTrackBar.Value; // Сохраняем информацию в файле settings.Save(); // Закрываем форму и завершаем приложение Close(); } // Обработчик нажатия кнопки Отмена private void cancelButton_Click(object sender, EventArgs e) { // Просто завершаем приложение, не сохраняя изменения Close(); } }
Как говорилось в начале главы, диалоговое окно настроек хранителя экрана должно отображаться в 3-х случаях: Если приложение запущено без параметров. Если приложение запущено с ключом /C. Если приложение запущено с параметром вида /C:n, где n – дескриптор диалогового окна Display Properties. Например, /C:299792. Для начала, мы можем попробовать добавить в функцию Main статического класса Program следующий код: Листинг 4.16. static void Main() { // Если приложение запущено без параметров или первый параметр начинается на “/C” if ((args.Length == 1) || ((args.Length == 2) && (args[1].Length >= 2) && (args[1].ToUpper().Substring(0, 3) == "/C"))) { // Отображаем диалоговое окно Application.Run(new SettingsForm()); return; } }
В принципе, подобный подход является вполне работоспособным, если не считать одной особенности: диалоговое окно может появиться на совершенно другом конце экрана (и даже на другом мониторе) относительно окна Display Properties. Для хранителя экрана, сделанного на профессиональном уровне, такое поведение не допустимо, поэтому нам необходимо решить данную проблему.
Рисунок 4.8. Начальное положение диалоговое окна Параметры не связано с текущим положением окна Display Properties.
4.5.1. Центрирование диалогового окна относительно Display Properties. Для выравнивания диалогового окна по центру окна Display Properties необходимо определить положение этого самого окна Display Properties на экране. Здесь самое время вспомнить о том, что при запуске хранителя экрана диалоговое окно Display Properties передаѐт ему в качестве параметра свой дескриптор. Ну а немного поколдовав с Win32 API над дескриптором окна, можно легко получить об этом окне практически любую информацию. Для начала мы добавим в класс диалогового окна ещѐ один конструктор, принимающий в качестве параметра дескриптор окна Display Properties (листинг 4.17). Листинг 4.17. public partial class SettingsForm : Form { // Необходимо ли центрировать окно параметров относительно окна Display Properties. По // умолчанию центрирование не выполняется bool center = false; // Дескриптор окна Display Properties IntPtr parentHandle; public SettingsForm(IntPtr parentHandle):this() { // Запоминаем дескриптор this.parentHandle = parentHandle;
// Включаем центрирование center = true; } ... }
Чтобы задействовать новый конструктор в коде функции Main необходимо реализовать более детальный разбор параметров с выделением из ключа вида /C:n значения дескриптора (листинг 4.18). Листинг 4.18. static void Main() { ... // Если приложение запущено без параметров (такое происходит, к примеру, при открытии окна // конфигурации при помощи контекстного меню) или с ключом “/C” if ((args.Length == 1) || ((args.Length >= 2) && args[1].ToUpper() == "/C")) { // Открываем диалоговое окно параметров приложения без центрирования Application.Run(new SettingsForm()); return; } // Если параметр хранителя экрана имеет вид /C:n if ((args.Length == 2) && (args[1].Length > 3) && (args[1].ToUpper().Substring(0, 3) == "/C:")) { // Выделяем из строки дескриптор окна и преобразуем его в значение типа IntPtr IntPtr hWnd = (IntPtr)int.Parse(args[1].ToUpper().Substring(3, args[1].Length - 3)); // Открываем диалоговое окно с выравниванием по центру окна Display Properties с дескриптором // hWnd Application.Run(new SettingsForm(hWnd)); } ... }
Переходим к самому интересному – получению информации о положении окна по его дескриптору. Немного порывшись в MSDN или в [К.26] мы обнаружим требуемую нам функцию: BOOL GetWindowRect(HWND hWnd, LPRECT lpRect);
где
hWnd – дескриптор окна;
lpRect – указатель на структуру RECT, в которую заносятся координаты верхнего левого и нижнего
правого углов окна. К сожалению .NET Framework 2.0 не содержит ни определение функции GetWindowRect, ни структуры RECT, так что нам придется определять их самим. Однако всѐ не так уж и плохо – определения большинства структур и функций для платформы .NET Framework, включая GetWindowRect и RECT, можно найти на сайте http://pinvoke.net. Так как методы Win32 API наверняка еще не один раз пригодиться нам, будет разумно вынести их в отдельный статический класс Win32 (листинг 4.19). Дополнительным плюсом подобного подхода является простая идентификация в тексте программы вызовов методов Win32, являющихся потенциальными источниками проблем при переносе приложения на другие платформы (например, на x64 или Xbox 360). Листинг 4.19. // Определения функций Win32 API, написанные на основе материалов сайта http://pinvoke.net using System.Runtime.InteropServices;
public static class Win32 { // Структура RECT [StructLayout(LayoutKind.Sequential)] public struct RECT { public int left; public int top; public int right; public int bottom; // Добавляем “от себя” два свойства, позволяющие легко определить ширину и высоту области public int Width { get { return right - left + 1; } } public int Height { get { return bottom - top + 1; } } } // Возвращает координаты окна [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);}
И, наконец, последний штрих – реализация в обработчике Load диалогового окна выравнивания по центру окна Display Properties (листинг 4.20). Листинг 4.20. private void SettingsForm_Load(object sender, EventArgs e) { if (center) { // Создаем структуру RECT Win32.RECT rect = new Win32.RECT(); // Получаем информацию о местоположении окна Display Properties Win32.GetWindowRect(parentHandle, out rect); // Вычисляем координаты центра окна Display Properties int centerX = (rect.right + rect.left) / 2; int centerY = (rect.bottom + rect.top) / 2; // Позиционируем наше диалоговое окно по центру окна Display Properties Left = centerX - Width / 2; Top = centerY - Height / 2; } }
Готовое приложение можно найти на CD диске в каталоге Examples\Ch04\Ex05.
4.6. Визуализация в окне предварительного просмотра. Переходим к заключительному и самому нетривиальному этапу – отображению хранителя экрана в окне предварительного просмотра. Чтобы реализовать эту функциональность приложение должно получить из командной строки дескриптор области предварительного просмотра (мониторчик на вкладке Screen Saver окна Display Properties) и создать в этом окне свой элемент управления. Для анимации созданного элемента управления окно Display Properties будет автоматически посылать ему сообщения WM_PAINT, а при выборе другого хранителя экрана, смене вкладки или закрытии диалогового окна Display Properties – сообщение WM_CLOSE. К сожалению, большую часть этой функциональности не возможно реализовать средствами .NET Framework поэтому на придется опуститься до уровня оконных процедур и циклов обработки сообщений Win32. Для начала определим все необходимые константы, структуры и функции Win32 (листинг 4.21).
Листинг 4.21. public static class Win32 { ... // Определение делегата оконной функции обработки сообщений. public delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); // Структура WNDCLASSEX, используемая при регистрации класса окна [StructLayout(LayoutKind.Sequential)] public struct WNDCLASSEX { [MarshalAs(UnmanagedType.U4)] public int cbSize; [MarshalAs(UnmanagedType.U4)] public uint style; public WndProc lpfnWndProc; public int cbClsExtra; public int cbWndExtra; public IntPtr hInstance; public IntPtr hIcon; public IntPtr hCursor; public IntPtr hbrBackground; public string lpszMenuName; public string lpszClassName; public IntPtr hIconSm; } // Битовые флаги стилей окна [Flags] public enum WindowStyles : uint { // Окно является дочерним WS_CHILD = 0x40000000, // Окно сразу же является видим WS_VISIBLE = 0x10000000, // Окно игнорирует действия пользователя WS_DISABLED = 0x08000000, ... } // Битовые флаги стилей класса окна [Flags] public enum ClassStyles : uint { // Окно будет использовать контекст устройства родительского окна CS_PARENTDC = 0x0080, ... } // Идентификаторы сообщений Windows public enum WindowsMessages : uint { WM_CLOSE = 0x10, WM_DESTROY = 0x2, WM_PAINT = 0xF, ... }
// Регистрирует класс окна [DllImport("user32")] public static extern short RegisterClassEx([In] ref WNDCLASSEX lpwcx); // Создает новое окно [DllImport("user32.dll")] public static extern IntPtr CreateWindowEx(uint dwExStyle, string lpClassName, string lpWindowName, uint dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); // Возвращает координаты клиентской области окна [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); // Уничтожает окно [DllImport("user32.dll")] public static extern bool DestroyWindow(IntPtr hWnd); // Помещает в очередь сообщений WM_QUIT, завершающее выполнение цикла обработки сообщений [DllImport("user32.dll")] public static extern void PostQuitMessage(int nExitCode); // Вызывает обработчик сообщения по умолчанию [DllImport("user32.dll")] public static extern IntPtr DefWindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); ... }
В функцию Main необходимо добавить код обработчика ключа /P, выполняющий следующие действия: 47. Получение дескриптора окна предварительно просмотра. 48. Определение размера окна предварительного просмотра. 49. Регистрация класса окна и создание дочернего окна в окне предварительного просмотра. 50. Создание экземпляра нашего класса Firework. 51. Запуск цикла обработки сообщений. Код, реализующий данную функциональность, приведен в листинге 4.22. Листинг 4.22. static void Main() { ... // Если приложение вызвано с ключом вида “/P n” if ((args.Length == 3) && (args[1].ToUpper() == "/P")) { // Получаем дескриптор окна предварительного просмотра IntPtr parentHandle = (IntPtr)uint.Parse(args[2]); // Определяем координаты клиентской области окна предварительного просмотра Win32.RECT rect; Win32.GetClientRect(parentHandle, out rect); // Создаем и заполняем структуру с информацией о классе окна Win32.WNDCLASSEX wndClassEx = new Win32.WNDCLASSEX(); wndClassEx.cbSize = Marshal.SizeOf(wndClassEx); wndClassEx.style = (uint)Win32.ClassStyles.CS_PARENTDC; // Указатель на оконную функцию (см. листинг 4.23). wndClassEx.lpfnWndProc = new Win32.WndProc(WindowProc);
wndClassEx.cbClsExtra = 0; wndClassEx.cbWndExtra = 0; wndClassEx.hIcon = IntPtr.Zero; wndClassEx.hIconSm = IntPtr.Zero; wndClassEx.hCursor = IntPtr.Zero; wndClassEx.hbrBackground = IntPtr.Zero; wndClassEx.lpszMenuName = null; wndClassEx.lpszClassName = "XNASCREENSAVER"; wndClassEx.hInstance = Marshal.GetHINSTANCE(typeof(Program).Module); // Регистрируем класс окна if (Win32.RegisterClassEx(ref wndClassEx) == 0) return; // Создаем дочернее окно для визуализации хранителя экрана displayHandle = Win32.CreateWindowEx(0, "XNASCREENSAVER", "XNAScreenSaver", (uint)(Win32.WindowStyles.WS_CHILD | Win32.WindowStyles.WS_VISIBLE | Win32.WindowStyles.WS_DISABLED), 0, 0, rect.Width, rect.Height, parentHandle, IntPtr.Zero, Marshal.GetHINSTANCE(typeof(Program).Module), IntPtr.Zero); try { // Создаем экземпляр класса Firework и передаѐм ему дескриптор созданного окна. Размер искр и // частота их появления подобраны таким образом, что фейерверк нормально смотрелся в // маленьком окошке предварительного просмотра. firework = new Firework(displayHandle, 1.0f, 5); } catch (FireworkException ex) { MessageBox.Show(ex.Message, "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } // Запускаем цикл обработки сообщений. Application.Run(); return; } return; } }
Оконная функция нашего окна будет обрабатывать три сообщения (листинг 4.23): WM_PAINT – визуализация изображения. WM_CLOSE – освобождение ресурсов и удаление окна. WM_DESTROY – завершает работу приложения. Листинг 4.23. // Оконная функция public static IntPtr WindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam) { switch (uMsg) { // Обработчик сообщения WM_PAINT case (uint)Win32.WindowsMessages.WM_PAINT: if (firework != null) {
// Обновляем состояние сцены firework.Update(); // Визуализируем сцену firework.Paint(); } return IntPtr.Zero; // Обработчик сообщения WM_CLOSE case (uint)Win32.WindowsMessages.WM_CLOSE: if (firework != null) { // Освобождаем ресурсы firework.Dispose(); firework = null; } // Уничтожаем окно Win32.DestroyWindow(displayHandle); return IntPtr.Zero; // Обработчик события WM_DESTROY case (uint)Win32.WindowsMessages.WM_DESTROY: // Выходим из цикла обработки сообщений Win32.PostQuitMessage(0); return IntPtr.Zero; default: return Win32.DefWindowProc(hWnd, uMsg, wParam, lParam); } }
Для проверки функционирования окна предварительного просмотра скомпилируйте проект хранителя экрана и установите его при помощи контекстного меню. Если всѐ сделано правильно, то на мониторе в окне предварительного просмотра появится фейерверк искр (рисунок 4.9).
Рисунок 4.9. Визуализация фейерверка в окне предварительного просмотра.
4.7. Создание дистрибутива. После окончания создания хранителя экрана самое время задуматься о его распространении. А именно, о создании дистрибутива, позволяющего неподготовленному пользователю легко инсталлировать и деинсталлировать хранитель экрана. Ведь как гласит народное мудрость, любое приложение встречают по одежке. В принципе, никто не мешает создать дистрибутив прямо на месте, не выходя из Visual Studio 2005. Что мы сейчас и сделаем. Итак, добавьте в решение новый проект инсталлятора ( Add | New Project | Other Project Types | Setup and Deployment | Setup Project). В свойствах ProductName и Manufacture проекта инсталлятора укажите название приложения и организации, которые будет отображаться, к примеру, в окне Add or Remove Programs.
Рисунок 4.10. Добавление в решение проекта инсталлятора.
П р им еч а н ие Дистрибутив, созданный подобным образом, использует технологию Windows Installer – сервис установки и конфигурирования программных продуктов, являющийся неотъемлемой частью операционных систем Windows 2000 и выше. Хотя данная технология изначально разрабатывалась для развертыванию и сопровождения корпоративного программного обеспечения, она активно используется большинством разработчиков программного обеспечения. Тем не менее “корпоративные корни” дают о себе знать – дистрибутив, использующий Windows Installer, несколько крупнее дистрибутивов, сгенерированных альтернативными инструментами (например, Nullsoft Scriptable Install System). В прочем эпоху широкополосных каналов Internet и винчестеров объемом в сотни гигабайт лишние 500 килобайт уже не строят погоды.
Всѐ что требуется от нашего инсталлятора – скопировать файл хранителя экрана в каталог Windows и выбрать его в качестве текущего хранителя экрана. По умолчанию папка, в которое устанавливается приложение, расположена внутри каталога Program Files. Чтобы приложение устанавливалось в каталог Windows в окне File System (Setup) щелкните на элементе Application Folder и измените значение свойства Default Location с [ProgramFilesFolder][Manufacturer]\[ProductName] на [WindowsFolder] (рисунок 4.11). Добавьте в папку Application Folder ссылку на *.scr файл хранителя экрана из каталога ...\bin\Release (эту операцию можно выполнить при помощи команды Add | File… контекстного меню элемента Application Folder). Обратите внимание, что в папку Application Folder будет автоматически добавлена и ссылка на сборку Microsoft.Xna.Framework.dll, используемую хранителем экрана. В
принципе, эту сборку вполне можно исключить из проекта, присвоив свойству Exclude значение true, однако в этом случае в описание хранителя экрана обязательно нужно указать, что для его нормального функционирования наряду с .NET Framework 2.0 и свежей версией DirectX, требуется установить и Microsoft XNA Framework.
Рисунок 4.11. Изменение каталога по умолчанию, в которое устанавливается приложения.
Полный путь исполняемого файла текущего хранителя экрана хранится в значение SCRNSAVE.EXE раздела системного реестра HKEY_CURRENT_USER\Control Panel\Desktop. Соответственно, для смены текущего хранителя экрана достаточно всего лишь исправить данное значение системного реестра. Для реализации этой функциональности в окне Solution щелкните правой кнопкой мыши на проекте инсталлятора и выполните команду контекстного меню View | Registry. На экране появится окно с деревом ключей системного реестра. Выберите ключ HKEY_CURRENT_USER и создайте в нем ключ Control Panel (команда контекстного меню New | Key), а в нем ключ Desktop. В ключе Desktop создайте строковое поле SCRNSAVE.EXE (команда контекстного меню New | String Value) и присвойте ему значение вида [TARGETDIR]mysaver.scr (рисунок 4.12), где
[TARGETDIR] – встроенный макрос, указывающий на каталог, в который устанавливается приложение (в нашем случае это \Windows).
mysaver.scr – имя файла хранителя экрана.
Рисунок 4.12. Регистрация в системном реестре текущего хранителя экрана.
Все было просто замечательно, если бы не один нюанс – значение поля SCRNSAVE.EXE должно быть коротким именем файла (это ограничение актуально даже для Windows XP Service Pack 2). По видимости, это пережиток, оставшийся со времен Windows 3.x, однако с ним приходится считаться. Обойти его в лоб весьма непросто, так не возможно заранее со 100% вероятностью предсказать короткое имя для заданного длинного имени файла. К счастью заботливые разработчики Windows предусмотрели альтернативный способ установки текущего хранителя экрана, не критичный к длине имени файла хранителя экрана. Этот способ основан на использовании динамической библиотеки desk.cpl, которая собственно и реализует окно Display Properties. Данная библиотека экспортирует ряд функций, предоставляющие доступ пакетным файлам, скриптам и прикладным приложениям к функциональности окна Display Properties. В частности функция InstallScreenSaver устанавливает текущий хранитель экрана. Для вызова этой функции можно воспользоваться утилитой rundll32: rundll32.exe desk.cpl,InstallScreenSaver {имя файла хранителя экрана}
Таким образом, нам необходимо, чтобы инсталлятор по окончанию копирования файлов в каталог Windows вызывал утилиту rundll32, с заданными параметрами.
4.7.1. Использование Custom Actions Технология Windows Installer, используемая Visual Studio, позволяет по завершению установки приложения запустить код из пользовательской сборки, выполняющий некоторые нестандартные действия. Данная функциональность получила называние Custom Actions. Итак, давайте создадим сборку, которая будет изменять текущий хранитель экрана посредством утилиты rundll32. Для начала добавьте в решение проект сборки новой библиотеки с названием SetupCustomActions (команда контекстного меню Add | New Project… | Visual C# | Class Library). Щелкните в окне Solution на узле сборки и добавьте класс инсталлятора SetCurrentScrenSaver (Add | New Item… | Installer Class). В проект будет добавлен новый класс, наследник Installer (листинг 4.24). Листинг 4.24. using using using using
System; System.Collections.Generic; System.ComponentModel; System.Configuration.Install;
namespace SetupCustomActions { // Атрибут RunInstaller, установленный в true, указывает на то, что данный класс будет // автоматически использоваться инсталлятором при установке приложения [RunInstaller(true)] public partial class SetCurrentScrenSaver : Installer { public SetCurrentScrenSaver() { InitializeComponent(); } } }
Класс Installer является каркасом, обеспечивающим базовую функциональность Custom Actions. Для добавления новых действий, выполняемых при инсталляции приложения, необходимо переопределить виртуальный метод Install класса Installer (листинг 4.25). Листинг 4.25. using System.Diagnostics; public override void Install(System.Collections.IDictionary stateSaver) { // Вызываем оригинальный метод класса Installer base.Install(stateSaver);
// Вызываем утилиту rundll32 Process.Start("rundll32.exe", "desk.cpl,InstallScreenSaver " + Context.Parameters["ScreenSaver"]); }
Информация о местоположении файла получается при помощи свойства Context.Parameters, содержащего ассоциативный массив параметров, переданных данной сборке (передачу параметров в сборку мы рассмотрим чуть ниже). Скомпилируйте созданную библиотеку классов. Теперь нам необходимо включить полученную сборку в состав дистрибутива. Для этого в окне Solution щелкните правой кнопкой мыши на узле проекта установки (Setup) и выполните команду контекстного View | Custom Actions. Откроется окно редактора Custom Actions, содержащее иерархический список действий, которые выполняются при инсталляции приложения, деинсталляции, откате изменений и т.п. Щелкните правой кнопке на узле Install (действия, выполняемые при установке приложения) и выполните команду контекстного меню Add Custom Action…. В появившееся диалоговом окне необходимо выбрать папку, в которую будет скопирована сборка, реализующая Custom Actions. В нашем случае просто выберите в выпадающем списке в верхней части окна папку Application Folder (каталог, в который устанавливается приложение). Затем нажните кнопку Add Output…, и в выпадающем списке Project открывшегося окна выберите проект, содержащий сборку с Custom Actions (как вы помните, мы еѐ назвали SetupCustomActions). Наконец, в списке в центральной области окна выберите элемент Primary output (файл, полученный после компиляции указанного проекта) и нажмите Ok (рисунок 4.13).
Рисунок 4.13. Добавление в дистрибутив сборки, реализующей Custom Actions.
После выполнения вышеперечисленных действий в окне Custom Actions у элемента Install появится дочерний узел Primary output from SetupCustomActions (Active). Ну а так как сборка SetupCustomActions содержит класс, производный от Installer, объявленный с атрибутом [RunInstaller(true)], данный класс будет автоматически использоваться при инсталляции приложения. Правда, просто вызвать класс ещѐ не достаточно – как вы помните, необходимо еще передать сборке параметр ScreenSaver с полным именем файла хранителя экрана. Для этого свойству CustomActionData узла Primary output from SetupCustomActions (Active) достаточно присвоить значение /ScreenSaver="[TARGETDIR]Firework XNA.scr" (рисунок 4.14).
Рисунок 4.14. Задание параметров, передаваемых в сборку.
4.7.2. Интеграция дистрибутивов .NET Framework 2.0 и XNA Framework 1.0 Наш хранитель экрана не является вещью в себе и зависит от ряда компонентов, которых может не оказаться на компьютерах потенциального пользователя. Это 52. .NET Framework 2.0 53. XNA Framework 1.0. Если хотя бы один из этих компонентов не будет установлен на компьютере пользователя, работоспособность хранителя экрана окажется под вопросом. Конечно, можно разместить на диске (или сайте) хранителя экрана дистрибутивы данных компонентов. Однако, ручная установка нескольких компонентов весьма утомляет, кроме того пользователь может банально забыть установить требуемый компонент. В Visual Studio 2005 эта задача решается путем интеграции необходимых компонентов непосредственно в дистрибутив приложения. В этом случае, при запуске программы установки приложения производится проверка наличия требуемых компонентов с последующей доустановкой недостающих частей. Данная функциональность реализуется очень просто. Достаточно открыть свойства проекта инсталлятора (команда контекстного меню Properties), нажать кнопку Prerequisite и в появившемся диалоговом окне выбрать компоненты, которые должны быть установлены на компьютер пользователя (рисунок 4.15). Чтобы поместить выбранные компоненты непосредственно в дистрибутив необходимо установить переключатель Specify the install location for prerequisites в значение Download prerequisites from the same location as my application.
Рисунок 4.15. Интеграция компонентов в дистрибутив приложения.
Единственная загвоздка заключается в том, что в состав XNA Game Studio 1.0 Express не входит компонент Prerequisite для Visual Studio 2005. Поэтому вам придется установить его с CD с книги. Для этого откройте каталог \Tools\XNA Game Studio Express 1.0\VS 2005 Prerequisite и запустите файл install.bat, после чего в списке диалогового окна Prerequisite появится элемент Microsoft XNA Framework. Если файл install.bat вдруг не сможет обнаружить местоположение Visual Studio, скопируйте вручную подкаталог XNAFramework в \Microsoft Visual Studio 8\SDK\v2.0\BootStrapper\Packages\. Дополнительная информация Каталог XNAFramework содержит три файла: xnafx_redist.msi – дистрибутив XNA Framework из XNA Game Studio Express 1.0 (каталог \XNA Game Studio Express\v1.0\Redist\XNA FX Redist). product.xml – описание условий, при которых устанавливается пакет xnafx_redist.msi. \En\package.xml – локализация для английского языка Информацию о создании Prerequisite для XNA Framework можно найти в [С.12]. Ну что ж, осталось только создать инсталлятор (команда контекстного меню Build), после чего в каталоге \Setup\Release появится проект готового инсталлятора, который можно смело раздавать своим знакомым без риска быть заваленным вопросами наподобие “что такое XNA Framework и где его взять?”.
Рисунок 4.16. Установка хранителя экрана на компьютер.
Рисунок 4.17. Предложение установить на компьютер недостающие компоненты.
Заключение В этой главе были подробно рассмотрены все нюансы создания полноценного хранителя экрана, начиная с написания обычного полноэкранного приложения, завершающего работу при активности пользователя, и заканчивая выводом изображения на все мониторы компьютера, реализацией диалогового окна конфигурации хранителя экрана и визуализацией в окне предварительного просмотра. Так же было продемонстрировано применение технологии Windows Installer для создания дистрибутива хранителя экрана, содержащего все необходимые компоненты, включая .NET Framework 2.0 и XNA Framework 1.0.
Глава 5. Вершинные шейдеры Вот уже на протяжении трех глав мы активно используем в приложениях вершинные и пиксельные шейдеры, однако их роль сводится к банальному пропусканию через себя исходных данных без какой-либо обработки или модификации. Такой подход трудно назвать оптимальным, ведь главное предназначение шейдеров – разгрузка центрального процессора компьютера путем освобождения его от рутины. Но для этого необходимо более детально изучить язык HLSL и получить представление об архитектуре графического процессора и языках ассемблера. В виду обширности этой темы данная глава посвящена преимущественно программированию вершинных процессоров, а программирование пиксельных процессоров будет детально рассмотрено в седьмой главе. П р им еч а н ие Если вы немного подзабыли основы языка HLSL, можете ещѐ раз пролистать раздел 2.3.
5.1. Математические вычисления в HLSL Функциональность любого шейдера так или иначе связана с математическими расчетами, поэтому для начала мы научимся выполнять математические операции над типами языка HLSL.
5.1.1. Математические операторы Математические операторы языка HLSL частично повторяют операторы языка C. Поддерживаются операторы +, -, *, /, %, ++, --, +=, -=, *=, /= и %=. Эти операторы можно применять как над скалярными типами, так и над векторами. Операции над скалярными типами полностью аналогичны операциям языка C. Во втором случае операции осуществляется покомпонентно над элементами векторов: float4 a = {1, 2, 3, 4}; float4 b = {5, 6, 7, 8}; // Складываем два вектора. Результат равен {1+5, 2+6, 3+7, 4+8} = {6, 8, 10, 12} float4 c = a+b; // Умножаем два вектора. Вектор d станет равен {1*5, 2*6, 3*7, 4*8} = {5, 12, 21, 32} float4 d = a*b;
Если в выражении одновременно используются скалярный и векторный тип, то операция выполняется над скалярным типом и каждым компонентом вектора: float4 a = {1, 2, 3, 4}; // Вектор b станет равен {1*2, 2*2, 3*2, 4*2} = {2, 4, 6, 8} float4 b = a*2;
Независимо от используемых типов вычисления всегда выполняются 32-битной точностью для каждого компонента62. Таким образом, замена в вышеприведенном коде типов float4 на half4 или double4 некоим образом не скажется на скорости или точности расчетов63.
5.1.2. Работа с компонентами векторов DirectX предоставляет множество способов доступа к компонентам вектора. Во-первых, программист может работать с компонентами вектора как с элементами массива. В этом случае компоненты номеруются с нуля, а доступ к ним осуществляется с использованием оператора []. Например, для вычисления среднего арифметического всех компонентов вектора можно воспользоваться следующим выражением: float4 color = float4(0.2, 0.7, 0.5, 1.0); // avg будет присвоено значение 1.6 float avg = (color[0] + color[1] + color[2] + color[3])/4;
Так как векторы очень часто используются для хранения геометрических координат и информации о цвете, DirectX предоставляет программисту возможность обращаться к компонентам вектора как к полям структуры. К нулевому элементу вектора можно обращаться как полю x или r, первому – y или g, второму – 62
Это утверждение верно только для вершинных шейдеров. В пиксельных шейдерах точность вычислений зависит от множества факторов (см. раздел 7.x). 63 А вот использование целочисленных типов вроде int4 может привести к тому, что компилятор HLSL будет пытаться честно эмулировать целочисленные вычисления посредством 32-х битных типов с плавающей точкой (см. раздел 2.3.2).
z или b, третьему – w или a. Нетрудно догадаться, что идентификаторы x, y, z, w предназначены для работы с геометрическими координатами, а идентификаторы r, g, b, a – для работы с цветовыми каналами: float avg = (color.r + color.g + color.b + color.a)/4;
или float avg = (color.x + color.y + color.z + color.w)/4;
При выполнении операций над векторами часто возникает необходимость выделить из вектора некоторый подвектор или переставить компоненты вектора местами. Так как современные графические ускорители поддерживают подобные операции аппаратно, разработчики HLSL встроили непосредственно в сам язык возможность гибкой работы с компонентами вектора. Например, для создания нового вектора путем комбинации компонентов существующего вектора достаточно просто перечислить после оператора “.” (точка) необходимые компоненты: // Создаѐм четырѐхмерный вектор float4 a={1, 2, 3, 4}; // Присваиваем двухмерному вектору b нулевой и первый элементы вектора a. Результирующее // значение вектора b будет равно (1, 2) float2 b=a.xy; // Присваиваем вектору c значение {1, 1, 2} float3 c=a.xxy; // Переставляем координаты x, y, z местами. Результирующее значение вектора a будет равно // {3, 2, 1, 4} a.xyz=a.zyx;
П р им еч а н ие Приложение должно трактовать компоненты вектора либо как цветовые каналы, либо как геометрические координаты. Комбинирование в одном выражении различных типов наименований запрещено. В частности, компилятор HLSL откажется компилировать выражение вроде a.rgzw, так как первые два компонента вектора трактуются как цвет, а вторые два – как координаты.
Другая любопытная особенность языка HLSL заключается в том, что скалярные типы фактически являются одномерными векторами. Это позволяет обращаться к скалярному типу как к массиву или структуре. Например, код float a=3; float4 v=float4(a, a, a, a);
можно переписать следующим образом: float a=3; // Обращаемся к скалярному типу как к одномерному вектору float4 v=a.xxxx;
Присвоение всем компонентам вектора одного и того же значения является довольно распространѐнной операцией. Поэтому в HLSL предусмотрен специальный синтаксис для выполнения этой операции: при присвоении вектору скалярного выражения с использованием оператора = оно автоматически заносится во все компоненты вектора. Например: float a=3; // В вектор v будет занесено значение (3, 3, 3, 3) float4 v=a;
5.1.3. Математические функции В языке HLSL имеется множество математических функций для работы со скалярными и векторными типами: тригонометрические и гиперболические функции, вычисление скалярного и векторного произведения векторов и так далее. Полный список функций языка HLSL можно найти в приложении 4. Обратите внимание, что список доступных функций определяется используемым профилем. Большинство функций HLSL транслируются в одну команду графического процессора. При этом, каждая команда графического процессора, как правило, выполняется за 1 такт. Поэтому рекомендуется как можно активнее использовать встроенные функции, а не изобретать велосипед. Например, выражение b
1 a
можно записать как b=1.0/sqrt(a), либо как b=rsqrt(a). Первый вариант будет транслирован в две
команды GPU (вычисление квадратного корня и деление), а второй – в одну. Нетрудно догадаться, что какой из них будет работать быстрее. П р им еч а н ие Оптимизирующий компилятор HLSL, скорее всего, самостоятельно заменит выражение b=1.0/sqrt(a) на b=rsqrt(a). Однако в более сложных случаях у него может не хватить сообразительности, чтобы подобрать оптимальную замену.
5.1.4. Черно-белая закраска В качестве демонстрации практического использования математических расчетов мы напишем простой эффект, преобразующий цвет примитивов в черно-белый с использованием следующего выражения:
l ( r g b) / 3 r l g l bl
(5.1)
где
r, g, b – красный, зелѐный и синий цветовые каналы
l – яркость
Это преобразование можно вставить в вершинный или пиксельный шейдер. В первом случае, цвета вершин вначале будут преобразованы в черно-белый цвет, после чего полученные черно-белые значения будут интерполироваться вдоль поверхности примитива. Следовательно, при визуализации нашего квадрата с помощью примитивов PrimitiveType.TriangleStrip преобразование в черно-белый цвет будет выполнено четыре раза – по одному для каждой вершины. При вынесении расчетов по формуле 5.1 в пиксельный шейдер, преобразование в чѐрно-белый цвет будет выполняться уже при вычислении цвета каждого пикселя. Например, когда визуализируемый объект занимает 56% площади окна размером 640x480, преобразование в чѐрно-белый цвет будет осуществляться примерно 640·480·0.56=172000 раз. То есть, по сравнению с первым вариантом объѐм вычислений возрастет в 172000/4=43000 раз (!), что не может не сказаться на производительности приложения. При увеличении размера окна до 1280x960 эта цифра возрастѐт ещѐ в четыре раза. Таким образом, мы можем сформулировать одно простое правило – при написании эффекта необходимо стремиться вынести как можно больше операций из пиксельного в вершинный шейдер. Поэтому в нашем эффекте мы разместим преобразование в черно-белый цвет именно в вершинном шейдере (листинг 5.1). Листинг 5.1. struct VertexInput { float3 pos : POSITION; float4 color : COLOR; }; struct VertexOutput { float4 pos : POSITION; float4 color : COLOR; };
VertexOutput MainVS(VertexInput input) { VertexOutput output; output.pos = float4(input.pos, 1.0f); // Выполняем преобразование цвета вершины в черно-белый float luminance = (input.color.r+input.color.g+input.color.b)/3.0; output.color.r = luminance; output.color.g = luminance;
output.color.b = luminance; output.color.a = input.color.a; return output; } float4 MainPS(float4 color:COLOR):COLOR { return color; } technique BlackAndWhiteFill { pass p0 { VertexShader = compile vs_1_1 MainVS(); PixelShader = compile ps_1_1 MainPS(); } }
Наш первый вариант вершинного шейдера реализует выражение 5.1 в лоб без учета архитектурных особенностей современных графических процессоров и, соответственно, не является оптимальным. Например, ничто не мешает нам присвоить рассчитанное значение яркости сразу трем цветовым компонентам (листинг 5.2). Листинг 5.2. VertexOutput MainVS(VertexInput input) { VertexOutput output; output.pos = float4(input.pos, 1.0f); // Вычисляем яркость цвета и присваиваем еѐ красному, зелѐному и синему каналам output.color.rgb = (input.color.r+input.color.g+input.color.b)/3.0; output.color.a = input.color.a; return output; }
П р им еч а н ие Вполне вероятно, что оптимизирующий компилятор HLSL самостоятельно заменит в вершинном шейдере из листинга 5.1 три присваивания компонентам вектора r, g, b на одну векторную операцию. А может и не заменит... Поэтому имеет смысл выработать привычку активного применять векторные выражения, не особо полагаясь на сообразительность оптимизирующего компилятора.
После этих улучшений код нашего вершинного шейдера выглядит довольно оптимально. Однако его всѐ равно можно ещѐ немного улучшить. Давайте раскроем скобки в выражении (5.1):
1 1 1 l r g b 3 3 3
(5.2)
Если внимательно на него посмотреть, можно заметить, что оно является результатом скалярного произведения двух трѐхмерных векторов:
1 1 1 l ( , , ) ( r , g , b) 3 3 3
(5.3)
На первый взгляд выражение 5.3 кажется значительно более громоздким и вычислительно сложным по сравнению с выражением 5.1. Но это вовсе не так – любой современный графический процессор умеет аппаратно вычислять скалярное произведение векторов. Поэтому, если мы заменим выражение 5.1 на 5.3 и воспользуемся встроенной функцией dot (скалярное произведение), то производительность программы ощутимо возрастѐт (листинг 5.3).
Листинг 5.3. VertexOutput MainVS(VertexInput input) { VertexOutput output; output.pos = float4(input.pos, 1.0f); // Вычисляем скалярное произведение векторов. Второй параметр функции dot автоматически // преобразуется в трѐхмерный вектор (1/3.0, 1/3.0, 1/3.0) output.color.rgb = dot(input.color.rgb, 1/3.0); output.color.a = 1.0; return output; }
Использование эффекта Чтобы опробовать полученный эффект в полевых условиях мы напишем приложение, визуализирующее квадрат с разноцветными вершинами в черно-белом режиме (рисунок 5.1). Так как код приложения не содержит ничего выдающегося, я лишь приведу фрагмент обработчика события Load (листинг 5.4), а остальные подробности при необходимости вы легко сможете найти на CD в книги в каталоге Examples\Ch05\Ex01.
Рисунок 5.1. Квадрат, визуализированный с использованием черно-белого эффекта.
Листинг 5.4. public partial class MainForm : Form { // Файл эффекта, имитирующего черно-белую закраску const string effectFileName = "Data\\BlackAndWhiteFill.fx"; GraphicsDevice device = null; PresentationParameters presentParams; VertexDeclaration decl = null; // Массив вершин VertexPositionColor[] vertices = null; Effect effect = null; bool closing = false;
public MainForm() { InitializeComponent(); } private void MainForm_Load(object sender, EventArgs e) { // Создаем графическое устройство ... decl = new VertexDeclaration(device, VertexPositionColor.VertexElements); vertices = new VertexPositionColor[4]; // Заносим в массив вершин информацию о вершинах. // является черно-белым vertices[0] = new VertexPositionColor(new XnaGraphics.Color.Green); vertices[1] = new VertexPositionColor(new XnaGraphics.Color.YellowGreen); vertices[2] = new VertexPositionColor(new XnaGraphics.Color.White); vertices[3] = new VertexPositionColor(new XnaGraphics.Color.GreenYellow);
Обратите внимание, что цвет вершин не Vector3(-0.75f, -0.75f, 0.0f), Vector3(-0.75f, 0.75f, 0.0f), Vector3(0.75f, -0.75f, 0.0f), Vector3(0.75f, 0.75f, 0.0f),
// Загружаем и компилируем эффект ... } // Обработчики событий Paint, Resize, Closed и т.п. ... }
Практическое упражнение №5.1 Формула (5.1) предполагает, что человеческий глаз имеет одинаковую чувствительность к красному, зелѐному и синему цвету. В действительности это не так – например, человеческий глаз значительно более чувствителен к зелѐному цвету, чем к синему. Для учѐта этого факта национальным комитетом по телевизионным системам США (NTSC) было принято решение вычислять яркость по следующей формуле:
l 0.299 r 0.587 g 0.114 b
(5.4)
Создайте эффект, осуществляющий преобразование в чѐрно-белый цвет с использованием формулы 5.4. В качестве отправной точки можно воспользоваться примером Ch05\Ex01. Готовое приложение находится на CD с книгой в каталоге Examples\Ch05\Ex02.
5.2. NVIDIA FX Composer 2.0 До сих пор мы создавали файлы эффектов .fx в обыкновенном текстовом редакторе. В принципе, в этом нет ничего плохого. В конце концов, некоторые разработчики создают .NET приложения в простых текстовых редакторах с последующей компиляцией полученного .cs-файла из командой строки компилятором C# (csc.exe). Однако по мере усложнения разрабатываемых проектов использование специализированных средств разработки становится всѐ более актуальным. Как ни крути, та же IDE Visual Studio значительно облегчает процесс разработки благодаря умному редактору с подсветкой синтаксиса, технологии IntelliSense, интегрированному компилятору, отладчику и справочной системе. По мере изучения XNA наши эффекты будут становиться всѐ сложнее, поэтому будет разумно заблаговременно подыскать интегрированную среду разработки эффектов. В действительности, наш выбор не велик – на рынке сейчас господствуют два бесплатных пакета для разработки эффектов: ATI RenderMonkey 1.6 и NVIDIA FX Composer 2.0. В данной книге мы будем использовать NVIDIA FX Composer, так как он гораздо динамичнее развивается и очень хорошо интегрирован с инфраструктурой .NET Framework.
П р им еч а н ие Существенная часть NVIDIA FX Composer 2.0 написана на .NET. В частности, вы можете легко исследовать исходный код FX Composer 2.0 посредством .NET Reflector.
Что такое FX Composer 2.0? Если коротко, это аналог Visual Studio для разработки шейдеров с использованием таких языков, как HLSL, GLSL64 и Cg65. Возможно, это слишком громко сказано, ведь FX Composer уступает Visual Studio 2005 практически по всем параметрам: удобству пользовательского интерфейса, технологии IntelliSense, документации и так далее. Кроме того, в текущей версии FX Composer имеется ощутимое количество багов. Впрочем, в этом нет ничего удивительного, если сравнить количество человеко-часов, затраченных на создание Visual Studio и FX Composer. Кроме того, NVIDIA FX Composer является абсолютно бесплатным, что позволяет закрыть глаза на многие недостатки – как известно, на халяву и уксус сладок. FX Composer 2.0 в первую очередь ориентирован на работу с файлами формата COLLADA версии 1.4.1, поэтому для понимания основных принципов организации пользовательского интерфейса полезно ознакомиться с основами этого формата.
5.2.1. Формат COLLADA 1.4.1 COLLADA (COLLAborative Design Activity) – это кроссплатформенный открытый формат, используемый для обмена данными между приложениями создания цифрового контента (DCC66). Формат COLLADA основан на XML и задаѐтся XSD-схемой. Это очень универсальный формат, способный хранить множество видов контента: Трехмерные модели. Эффекты. Техники. Шейдеры. Материалы. Источники света. Камеры. Анимация. Физическая модель сцены. И т.д. и т.п. Кроме того, формат COLLADA позволяет сторонним разработчикам добавлять новые элементы XML, расширяя возможности формата почти до бесконечности. Рассмотрим основы формата COLLADA на примере простого файла: data/default_color.dds <effect id="BlinnEffect" name="Blinn Effect"> <profile_CG platform="PC-OGL"> <profile_CG platform="PS3">
64
OpenGL Shading Language (GLSL) – язык программирования шейдеров, используемый в API OpenGL. Cg – язык программирования шейдеров, разработанный корпораций NVIDIA. Поддерживает как API DirectX, так и API OpenGL. 66 Digital Content Creation (DCC) – создание цифрового контента. 65
<profile_GLSL> <extra type="import"> <material id="BlinnMaterial" name="Blinn Material">
Как видно, вся информация о контенте храниться в элементе :
В элемент вложены элементы, соответствующие разным типам контента: – растровые изображения; – эффекты; – материалы; – камеры; – источники света; – модели; и т.д. Каждый эффект определяется посредством элемента <effect>, вложенного в : <effect id="BlinnEffect" name="Blinn Effect"> Атрибут id задает уникальный идентификатор эффекта, а name – название эффекта,
отображаемое приложениями вроде FX Composer 2. Так как формат COLLADA не привязан к конкретной платформе или API, эффект может быть написан на разных языках для различных платформ. Это достигается посредством профилей: каждый эффект может содержать несколько профилей для разных API и платформ. Профили задаются элементами с названиями вида <profile_XXX>, вложенными в элементы <effect>: <profile_CG> – эффект написан на языке Cg. Атрибут platform позволяет специфицировать платформу, для которой предназначен эффект: например, значение "PC-OGL" указывает, что эффект предназначен для API OpenGL на платформе PC, а “PS3” – для платформы Playstation 3. <profile_GLSL> – эффект написан на языке GLSL. <profile_GLES> – эффект написан для API OpenGL ES67. <profile_COMMON> – платформо-независимый эффект, близкий по функциональности к стандартному материалу (Standard Material) из 3ds Max. 67
OpenGL ES – подмножество API OpenGL, используемое в встраиваемых системах: мобильных телефонах, игровых приставках и т.п.
Внутри элемента <profile> размещается ссылка на файл эффекта, а так же при необходимости различные сведения об эффекте: перечень техник, проходов и т.п. Кстати, код эффекта при желании тоже можно разместить непосредственно в элементе <profile> посредством элемента . Наверняка вы заметили, что в вышеприведенном списке нет профиля для языка HLSL. Дело в том, что в текущей версии (1.4.1) формата COLLADA пока отсутствует поддержка языка HLSL. Однако благодаря расширяемости данного формата разработчики могут легко реализовать дополнительную функциональность посредством элемента <extra>, в частности FX Composer помещает ссылку на .fxфайл следующим образом: <extra type="import">
Как видно, ссылка на эффект размещается в пользовательском профиле fx. Наряду с эффектами в файле формата COLLADA могут храниться материалы. Для понимания концепции материала рассмотрим простой пример. Допустим, мы разработали эффект, визуализирующий фрактал. Управляя входными параметрами эффекта68, мы можем визуализировать фрактальное изображение мрамора, дерева, воды, лавы и т.п. Тогда для каждого типа изображения мы создадим отдельные материалы мрамора, дерева, воды и лавы, использующие общий эффект фрактала, но с разными параметрами. Грубо говоря, материал это набор параметров для некоторого эффекта. определяются внутри элемента посредством элемента <material>. В элемент <material> в свою очередь вкладывается элемент , атрибут url которого ссылается на эффект из уже знакомой нам секции , используемый материалом. Кроме того, в секции размещаются значения различных параметров эффекта, Материалы
формирующие уникальный внешний вид материала. Элементы и со всеми вложенными элементами образуют подмножество формата COLLADA, известное как COLLADA FX. FX Composer 2 в первую очередь предназначен для работы именно с подмножеством COLLADA FX, остальные же элементы COLLADA поддерживаются в ограниченном объеме по мере необходимости. Например, FX Composer 2 может использовать трехмерные модели из файла формата COLLADA, однако возможности создания и редактирования трехмерных моделей весьма ограничены (но теоретически могут быть расширены посредством плагинов). Не переживайте, если вы не поняли часть материала. Цель этого раздела – просто познакомить вас с основными принципами устройства файлов формата COLLADA, знание которых поможет быстрее освоиться с весьма запутанным интерфейсом FX Composer 2.0.
5.2.2. Знакомство с интерфейсом FX Composer 2.0 Ну что ж приступим. Для начала установите FX Composer 2.069 и запустите его из меню Start (Start | All Programs | NVIDIA Corporation | FX Composer 2 | FX Composer 2). На рисунке 5.2 приведѐн внешний вид стартового экрана FX Composer сразу после установки70. Рассмотрим основные элементы пользовательского интерфейса. В верхней части окна расположено главное меню FX Composer, под которым находится панель инструментов (Standard Toolbar) для быстрого доступа к наиболее важным пунктам меню. Как и во всех современных IDE панель инструментов легко конфигурируется с учетом предпочтений пользователя.
68
Параметры эффектов будут рассмотрены в разделе 5.4. Инсталлятор FX Composer 2.0 находится на CD-диске с книгой в каталоге \NVIDIA. Последнюю версию FX Composer всегда можно найти по адресу http://developer.nvidia.com/ . 70 Чтобы придать скриншоту большую выразительность, я добавил в сцену чайник, создал несколько материалов и применил один из материалов к чайнику. В остальном же внешний вид приложения мало чем отличается от отображаемого при первом запуске. 69
П р им еч а н ие В FX Composer 2 имеется подробное руководство пользователя, которое можно открыть щелчком на ссылке User Guide на панели Start Page, отображаемой при первом запуске FX Composer.
В центре экрана расположены вкладки трех панелей: Start Page, Shader Library и Editor. Вкладка Start Page, аналогичная одноименному окну из Visual Studio 2005: здесь отображается информация о недавно открытых проектах (Recent), ссылки на документацию (Getting Started), перечень типовых действий вроде создания нового проекта или эффекта (Tasks) и новости с сайта. Обязательно ознакомьтесь с документацией по FX Composer (ссылка User Guide в разделе Getting Started). Вкладка Shader Library содержит коллекцию материалов из онлайновой библиотеки материалов NVIDIA. Вкладка Editor представляет собой текстовый редактор с подсветкой синтаксиса, используемый для написания и редактирования кода эффектов.
1 3
4
2
5
6 Рисунок 5.2. Стартовый экран FX Composer 2: 1 – главное меню и панель инструментов, 2 – панель Start Page , 3 – панель Materials, 4 – панель Properties, 5 – панель Render, 6 – панель Animation.
В левой части расположены вкладки трех панелей: Materials, Assets и Project. Панель Material, напоминающая редактор материалов из 3ds Max, позволяет работать с материалами, о которых мы уже говорили в разделе о формате COLLADA. Панель Assets предназначена для работы с различными группами контента, поддерживаемого форматом COLLADA. Панель Project, содержащая иерархию всех файлов проекта, в целом аналогична окну Solution Explorer из Visual Studio 2005. В правой верхней части окна расположена панель Properties, позволяющая задавать значения входных параметров эффектов и материалов с использованием интуитивно понятного интерфейса 71. Ниже расположена вкладка Render, которая позволяет опробовать созданный материал на тестовой сцене, 71
Параметры эффекта будут рассмотрены в разделе 5.4.
визуализируемой с использованием API OpenGL или DirectX (как вы помните, эффекты COLLADA могут содержать персональные профили для каждого API). В нижней части окна расположены панели Animation и Tasks. Панель Animation управляет ходом времени и используется в основном для тестирования анимированных материалов. В панели Tasks отображаются сообщения об ошибках компиляции эффекта, т.е. она является аналогом окна Error List из Visual Studio. Все панели не являются фиксированными: их можно легко перетаскивать с места на место, попутно изменяя размер. При этом панели автоматически приклеиваются к краям окон, встраиваются в другие панели, короче ведут себя так же, как и аналогичные панели из Visual Studio. Дополнительные панели72 можно отобразить на экране посредством меню View (рисунок 5.3).
Рисунок 5.3. Список панелей FX Composer в меню View.
П р им еч а н ие Меню View содержит пункт Layouts, позволяющий гибко конфигурировать расположение панелей FX Composer. Предусмотрено четыре типовых расположения панелей (подпункты Artist, Authoring, Default, Turning), кроме того предусмотрена возможность создания пользовательских конфигураций. Если вы вдруг перетащили панель куда-то не туда и не можете вернуть еѐ на прежнее место, просто выполните команду меню View | Layouts | Reset Layout.
5.2.3. Создание нового проекта Лучший способ изучить FX Composer 2.0 – начать его использовать на практике. В качестве упражнения мы создадим в FX Composer эффект черно-белой закраски. Переключитесь в FX Composer. Если вы уже экспериментировали с материалами и эффектами, то создайте новый проект, выполнив команду меню File | New | New Project. Отобразите вкладку Assets (рисунок 5.4). Как видно, эта вкладка содержит узлы, соответствующие наборам контента формата COLLADA, о которых мы немного поговорили в разделе 5.2.1. Например, узел Effects вкладки Assets соответствует элементу , узел Materials – элементу и т.п.
72
По умолчанию на экране присутствуют далеко не все имеющиеся панели.
Рисунок 5.4. Вкладка Assets.
Чтобы добавить в проект новый эффект щелкните правой кнопкой мыши на узле Effects и в появившемся контекстом меню выберите пункт Add Effect... . На экране появится мастер создания нового эффекта (рисунок 5.5). Так как мы будем использовать исключительно язык HLSL, установите флажок только рядом с профилем HLSL FX. В поле Effect Name введите название эффекта (например, BlackAndWhite). В нижней части окна можно указать название материала, создаваемого на базе данного эффекта, но так как материалы нам пока не нужны, мы не будем устанавливать данный флажок.
Рисунок 5.5. Мастер создания эффекта.
Перейдите к следующему диалоговому окну мастера Effect Wizard, нажав кнопку Next. Здесь вам потребуется указать шаблон, на основе которого будет создан эффект. Мы будем использовать шаблон Empty, который, как нетрудно догадаться, создает простейший эффект по умолчанию. В поле Name укажите название эффекта (например, BlackAndWhite.fx), а в поле Location – каталог, в который будет помещен файл эффекта. Наконец, завершите создание эффекта нажатием кнопки Finish.
Рисунок 5.6. Создание файла эффекта.
Теперь разверните узел Effects. Если всѐ было выполнено правильно, в узле Effects появится дочерний узел BlackAndWhite, инкапсулирующий эффект Collada, в который вложен собственно файл нашего эффекта BlackAndWhite.fx (рисунок 5.7). П р им еч а н ие Если бы мы при создании эффекта выбрали наряду с .fx ещѐ несколько профилей, то узел эффекта BlackAndWhite содержал бы несколько файлов эффектов с расширениями наподобие .cg или .glsl.
Рисунок 5.7. Узел созданного эффекта на панели Effects.
Чтобы открыть редактор кода выполните двойной щелчок левой кнопкой мыши на узле файла 73 BlackAndWhite.fx. Замените текст созданного по умолчанию эффекта кодом из листинга 5.1 . Обратите 73
Немного погодя мы оценим, насколько хорошо компилятор HLSL смог выполнить оптимизацию этого некачественного кода.
внимание на подсветку ключевых слов языка HLSL, значительно облегчающую поиск опечаток. По завершению набора кода эффекта выполните его компиляцию посредством сочетания клавиш Ctrl + F7. Если эффект содержит ошибки, то в окне Tasks появится перечень ошибок (рисунок 5.8), а сама строка содержащая ошибку будет подсвечена. П р им еч а н ие Чтобы видеть сообщения о ходе компиляции эффекта, откройте панель Output (рисунок 5.9) посредством команды главного меню View | Output.
Рисунок 5.8. Панель Task с информацией об ошибках в эффекте.
Рисунок 5.9. Панель Output с информацией о процессе компиляции.
В заключении не забудьте сохранить проект эффекта командой File | Save All.
Структура проекта Сохранив проект, запустите любой файловый менеджер и перейдите в каталог с проектом. Если вы сохранили проект и файл эффекта в одном и том же каталоге, то в нем будут находиться три файла: Project.fxcproj – файл проекта FX Composer с информацией о настройках IDE и файлах входящих в проект. Имеет формат XML. Document1.dae – файл формата COLLADA с информацией о контенте. BlackAndWhite.fx – собственно файл эффекта. Текст файла Document1.dae, сгенерированный FX Composer 2.0 на моем компьютере, приведен ниже: Sergei Gaidukov NVIDIA FX Composer 2.0 2007-06-04T16:39:20 FXComposer, NVIDIA <modified>2007-06-04T16:39:21 <subject/> <effect id="Effect" name="BlackAndWhite">
<profile_COMMON> <extra type="import">
Как видно, в элемент вложены два элемента: с информацией об авторе файла, времени его создания и приложении, в котором он был создан; и уже знакомый нам элемент . Последний содержит эффект с идентификатором Effect и именем BlackAndWhite, в котором определено два профиля: HLSL74 и COMMON. П р им еч а н ие Чтобы файл формата COLLADA мог корректно обрабатываться любым приложением, он должен содержать профиль COMMON.
Редактирование существующего .fx-файла. Если эффект изначально разрабатывался в FX Composer 2.0, то с его редактированием не возникнет проблем – достаточно просто открыть проект командой File | Open | Open Project... и продолжить работу. Но что делать, если необходимо подправить существующий .fx-файл? Так как организация проектов FX Composer 2.0 насквозь пронизана идеологией COLLADA, вы не можете просто так открыть существующий .fx-файл командой File | Open | Open File... – в этом случае вы потеряете возможность выполнять пробную компиляцию .fx-файла и, соответственно, не сможете обнаруживать синтаксические ошибки. К счастью, эта особенность легко обходится: вы должны просто создать новый эффект на основе вашего .fx-файла. Для этого откройте вкладку Assets, щелкните правой мыши на узле Effects, выполните команду контекстного меню Add Effect From File... и укажите файл, который необходимо открыть. В результате в проект будет добавлен новый эффект, содержащий указанный файл, который теперь можно легко отредактировать и откомпилировать.
5.2.4. Анализ производительности эффекта. При разработке шейдеров начинающие разработчики часто оказываются в положении буриданова осла, когда одну и ту же функциональность можно реализовать различными способами и при этом не совсем ясно, какой из них будет иметь большую производительность. В подобных ситуациях трудно переоценить полезность панели Shader Perfomance, позволяющей быстро прикинуть быстродействие эффекта на различных видеокартах NVIDIA с учетом многочисленных версий драйверов. Чтобы получить представление о возможностях данной панели мы проанализируем производительность эффекта BlackAndWhite, созданного в разделе 5.2.3. Для начала в панели Assets щелкните правой кнопкой мыши на узле эффекта, который вы собираетесь проанализировать, и выберите команду контекстного меню Analyze Performance, после чего в нижней части окна появится панель Shader Performance (рисунок 5.10), содержащая две вкладки: Startup Form и BlackAndWihite.fx. Вторая вкладка, как нетрудно догадаться, предназначена для анализа нашего эффекта, а первая используется преимущественно для загрузки новых эффектов в панель Analyze Performance.
74
Так как текущая версия формата COLLADA не поддерживает профиль HLSL, он объявляется посредством элемента <extra>, расширяющего возможности формата.
Рисунок 5.10. Панель Shader Performance.
В левом верхнем углу вкладки BlackAndWhite.fx расположен переключатель между режимом анализа производительности единственного выбранного прохода эффекта (Analyze a Pass) и режимом сравнения производительности всех проходов эффекта (Compare Passes). Так как наш эффект содержит единственный проход, оба варианта будут практически эквивалентны. Ниже расположены флажки списка техник эффекта 75. Мы естественно выберем для анализа единственную технику нашего эффект p0. Ещѐ ниже имеется выпадающий список для выбора анализируемого типа шейдера: вершинного или пиксельного. Пиксельный шейдер эффекта BlackAndWhite.fx, содержащий единственный оператор return, вряд ли нуждается в какой-либо оптимизации, поэтому мы будем анализировать вершинный шейдер. Наконец, в самом низу вкладки BlackAndWhite.fx находятся списки флажков Drivers и GPU, позволяющие выбрать версии драйверов ForceWare и графические процессоры, на которых будет эмулироваться выполнение эффекта. Указав всю требуемую информацию можно приступать к собственно исследованию производительности вершинного шейдера. Для анализа эффекта с учетом выбранных параметров необходимо нажать кнопку Run на панели в верхней части окна. Для просмотра результатов анализа в виде таблицы нажмите кнопку Table, после чего вы увидите информацию аналогичную рисунку 5.10. Как видно, при использовании видеокарты NVIDIA GeForce 7800 GTX с драйверами ForceWare 162.03 выполнение вершинного шейдера будет длиться 7 тактов, а всего за одну секунду всеми вершинными процессорами этой видеокарты будет обработано 491.000.000 вершин. Но следует учитывать, что эта астрономическое число отражает пиковую производительность без учета быстродействия остальных компонентов видеокарты, так что реальная производительность наверняка окажется ощутимо скромнее. Например, видеокарта может оказаться просто не в состоянии закрасить треугольники, содержащие вершины. П р им еч а н ие Кнопки Precision и Branches позволяют просмотреть более подробный отчет с учетом различной точности вычислений и сценариев выполнения условных переходов. Но в настоящее время эти возможности являются для нас избыточными: эффект BlackAndWhite.fx не содержит условных переходов, а точность вычислений вершинных шейдеров всегда равна 32-бита.
Кнопка Graph позволяет в наглядной форме сравнить производительность шейдера на разных видеокартах. Перед выполнением сравнения необходимо составить перечень интересующих вас видеокарт и драйверов. Для этого откройте командой главного меню Tools | Settings... диалоговое окно Settings с настройками FX Composer и в древовидном списке в левой части окна выделите узел Enviroment | ShaderPerf. Затем в левой части окна щелкните на кнопке “...” напротив опции DefaultSelectedGPUs и в появившемся диалогов окне Selected Default GPUs установите флажки напротив интересующих вас графических процессоров (рисунок 5.11). Например, если вы разрабатываете приложение для видеокарт семейства GeForce 6 и выше, вам следует пометить все GPU семейств NV4x и G7x. Далее аналогичным образом укажите интересующие вас версии драйверов.
75
В режиме Analyze a Pass можно выбрать только одну технику, а в режиме Compare Passes – соответственно несколько.
Рисунок 5.11. Диалоговое окно Setting и Select Default GPUs.
После нажатия OK в во вкладке BlackAndWhite.fx панели Shader Perfomance появятся флажки, соответствующие указанным графическим процессорам. Выделите флажки GPU и драйверов, интересующие вас в данный момент, и нажмите кнопку Graph в верхней части окна. На экране появится диаграмма производительности вершинного шейдера на выбранных графических процессорах (рисунок 5.12).
Рисунок 5.12. Производительность шейдера на различных графических процессорах.
По диаграмме легко можно оценить, будет ли являться производительность вершинного шейдера ограничивающим фактором. Допустим, наша сцена содержит 100.000 треугольников. Значит, пренебрегая
прочими факторами можно предположить, что визуализация данного сцены на самой медленной видеокарте семейства GeForce6 (GeForce 6200) будет выполняться с частотой 150.000.000 / 100.000 = 1.500 кадров секунду. Таким образов в данном конкретном случае вершинный шейдер не станет узким местом даже на самых дешевых видеокартах семейства GeForce6.
Просмотр ассемблерного кода шейдера. Еще одной интересной возможностью панели Shader Performance является просмотр скомпилированного промежуточного кода шейдера. Это очень мощная функциональность, позволяющая оценить качество сгенерированного ассемблерного кода и увидеть ошибки, допущенные компилятором. Возможно, последнее утверждение покажется вам несколько надуманным, но это действительно так. Компилятор HLSL в настоящее время значительно менее отлажен по сравнению с теми же компиляторами C++, а сами графические процессоры содержат множество ограничений. Например, компилятор может сгенерировать несколько отличный код от ожидаемого вами, в результате чего выполнение эффекта будет сопровождаться нежелательными эксцессами наподобие переполнения разрядной сетки в ходе промежуточных расчетов. В будущем вы практически гарантированно столкнетесь с подобными аномалиями, причем, чем меньшими возможностями обладает используемая видеокарта, тем более вероятно возникновение проблем. П р им еч а н ие Это особенно актуально при написании пиксельных шейдеров для GeForce3 и GeForce4, регистры которых имеют ограниченную разрядность и рассчитаны на работу с числами в диапазоне от -1 до +1. Данная тема будет подробно рассмотрена в седьмой главе.
Чтобы увидеть ассемблерный код шейдера, сгенерированный компилятором HLSL, достаточно нажать кнопку ASM после чего во вкладке Editor появится вкладка BlackAndWhite_Asm.txt со следующим текстом: ################################################################ # Technique: BlackAndWhiteFill # Pass: p0 ################################################################ // // Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 add r0.w, v1.y, v1.x add r0.w, r0.w, v1.z mul oD0.xyz, r0.w, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy mov oD0.w, v1.w // approximately 5 instruction slots used
Но сейчас мы можем почерпнуть из этого отчета разве то, что он был создан компилятором Microsoft (R) D3DX9 Shader Compiler версии 9.12.589.0000, причем в качестве промежуточного языка был использован Vertex Shader 1.1. Всѐ остальное для нас не более чем китайская грамота, так что для оценки качества сгенерированного кода нам потребуется ознакомиться с азами языков Vertex Shader. На этом первое знакомство с NVIDIA FX Composer 2.0 можно считать оконченным, но мы ещѐ не раз встретимся с ним в следующих главах по мере роста сложности наших приложений.
5.3. Введение в языки Vertex Shader Языки семейства Vertex Shader предназначены для программирования виртуальных вершинных процессоров, причем каждому языку соответствует своя модель виртуального вершинного процессора. Вообще в основу каждой модели виртуального вершинного процессора положен вполне определенный реальный вершинный процессор, однако на практике данная особенность не играет особой роли, ведь код для виртуального процессора всѐ равно компилируется драйвером видеокарты в машинный код текущего процессора. Кроме того, все эти модели виртуальных процессоров построены на общих принципах. Так что вы вполне можете думать о языках Vertex Shader как о вариациях на тему языка IL, заточенных под векторные процессоры.
5.3.1. Регистры Любой виртуальный вершинный процессор содержит набор регистров, напоминающих регистры SSE76 процессоров архитектуры x86. Большинство регистров (но отнюдь не все) являются векторными регистрами, рассчитанными на хранение четырѐхмерных векторов в формате с плавающей точкой. Разрядность регистров может быть произвольной, но на практике обычно равна 128-ми битам, то есть на каждый компонент вектора, как правило, отводится 32-бита (рисунок 5.13). W 127
Z 95
Y 63
X 31
0
Рисунок 5.13. 128-битный векторный регистр (32 бита на компонент).
Данные, с которыми работает виртуальный процессор можно разделить на три большие группы (рисунок 5.14): Исходные данные, передающиеся в вершинный шейдер через регистры исходных данных (координаты вершин, текущее время и т.д.). Промежуточные результаты, хранящиеся в регистрах общего назначения. Итоговые результаты, передаваемые дальше по графическому конвейеру посредством соответствующих выходных регистров вершинного процессора.
Регистры исходных данных
Вершинный процессор
Регистры итоговых результатов
Регистры общего назначения
Рисунок 5.14. Структура графического процессора.
Набор регистров существенно варьируется от версии к версии, поэтому чтобы сделать материал менее запутанным мы сосредоточимся исключительно на версии 1.1. П р им еч а н ие Виртуальные процессоры Vertex Shader 1.1 и Pixel Shader 1.1 практически один в один повторяют архитектуру вершинных и пиксельных процессоров видеокарты GeForce3 (NV20).
Регистры исходных данных Регистры исходных данных виртуального процессора делятся на две подгруппы (рисунок 5.15): 16 регистров v0, v1… v15 с информацией, специфичной для текущей вершины (Input Registers). Не менее 96-ти векторных константных регистров c0, c1 … c95 ... cN с информацией, общей для всех вершин (Constant Float Registers). Оба типа регистров являются векторными регистрами, в которых хранятся 4 компонента с плавающей точкой. У всех современных графических процессоров разрядность этих регистров равна 128 бит, т.е. каждый компонент вектора является 32-х разрядных числом с плавающей точкой. Но эта разрядность не является фиксированной и может измениться у будущих GPU. Кроме того, различные GPU могут несколько по-разному обрабатывать такие граничные ситуации как переполнение или деление на нуль, поэтому код шейдеров не должен быть заточен под фиксированную разрядность 32-бита на компонент. В регистры v0, v1 … v15 заносятся атрибуты77, специфичные для каждой вершины: информация о координатах вершины, еѐ цвете, размере точки, текстурных координатах и т.п. Связывание входного регистра с конкретным вершинным атрибутом осуществляется посредством специальных директив вида dcl_xxx, некоторые из которых перечислены в таблице 5.1. Компилятор HLSL отображает входные 76
128-ми битные векторные регистры SSE впервые появились в процессоре Intel Pentium-III, информацию об архитектуре которого можно найти, например, в [К.6]. 77 Входные параметры с информацией, специфичной для каждой вершины, часто называют атрибутами.
параметры вершинного шейдера на регистры v0 ... v15, таким образом, директивы dcl_xxx аналогичны семантикам входных параметров вершинного шейдера. В частности, если вы внимательно посмотрите на ассемблерный код, полученный посредством FX Composer в конце раздела 5.2.4, то обнаружите две директивы: dcl_position v0 dcl_color v1
Совершенно очевидно, что эти строки являются результатом компиляции структуры входной информацией вершинного шейдера: struct VertexInput { float3 pos : POSITION; float4 color : COLOR; };
Таблица 5.1. Некоторые директивы, связывающие входные параметры с атрибутами вершины Директива
Аналогичная семантика HLSL
Информация, которая будет заноситься во входной регистр
dcl_position
POSITION
Координаты вершины
dcl_color
COLOR
Информация о цвете вершины
dcl_psize
PSIZE
Размер визуализируемой точки78
dcl_texcoord
TEXCOORD
Текстурные координаты вершины
127
95
63
31
0
127
v0
c0
v1
c1
v2
c2
v3
c3
v4
c4
v5
c5
v6
c6
v7
c7
v8
c8
v9
c9
v10
c10
v11
c11
v12
c12
v13
c13
v14
c14
v15
c15
95
c16 ...
63
31
0
...
c93 c94 c95 ...
...
cN
Рисунок 5.15. Регистры исходных данных.
Константные регистры, как следует из названия, используются для хранения различных констант. Задание константы осуществляется посредством директивы def: 78
Управление размером отдельных точек будет рассмотрено в разделе 6.x.
def {константный регистр}, {компонент 0}, {компонент 1}, {компонент 2}, {компонент 3}
Например, следующая директива перед началом обработки вершин шейдером заносит в константный регистр c0 вектор (0.333333343, 1, 0, 0). def c0, 0.333333343, 1, 0, 0
Код данной директивы взят из ассемблерного листинга эффекта черно-белой закраски. Нетрудно догадаться, что компонент вектора со значением 0.333333343 впоследствии используется компилятором для вычисления выражения (input.color.r+input.color.g+input.color.b)/3.0. Так же логично предположить, что компонент со значением 1 используется при добавлении четвертого компонента к координатам вектора: output.pos = float4(input.pos, 1.0f);
Количество константных регистров зависит от видеокарты, однако поддержка видеокартой Vertex Shader 1.1 гарантирует наличие не менее 96 константных регистров. Точное количество константных регистров вершинных процессоров текущей видеокарты может быть получено посредством метода GraphicsDeviceCapabilities.MaxVertexShaderConstants класса GraphicsDevice. В таблице 5.2 приведена информация о количестве константных регистров у наиболее распространенных видеокарт. П р им еч а н ие Число физических константных регистров может несколько превышать значение, возвращаемое GraphicsDeviceCapabilities.MaxVertexShaderConstants. Дополнительные регистры, как правило, используются драйвером для внутренних нужд, например, при эмуляции фиксированного графического конвейера из прошлых версий DirectX.
Таблица 5.2. Количество константных регистров у некоторых GPU. GPU
Количество константных регистров у вершинных процессоров
NV2x
96
NV3x
256
NV4x
256
G7x
256
R2xx
192
R3xx
256
R4xx
256 79
Intel GMA 9xx
8192
Intel GMA 3000
8192
Забегая вперед, стоит отметить, что значения константных регистров могут изменяться приложением, что делает их идеальным средством для передачи параметров в вершинный шейдер (см. раздел 5.4).
Регистры общего назначения Регистры общего назначения используются для хранения операндов и результатов команд, а так же для адресации массива константных регистров. Виртуальный вершинный процессор Vertex Shader 1.1 предполагает наличие двух типов временных регистров (рисунок 5.16): 12 временных векторных регистров r0, r1 … r11 для хранения промежуточных результатов вычислений (Temporary Registers). Один скалярный адресный регистр a0, используемый для косвенной адресации константных регистров (Address Register).
79
Вершинные шейдеры на Intel GMA 9xx и GMA 3000 выполняются посредством CPU.
127
95
63
31
r0
0
a0
r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 Рисунок 5.16. Регистры общего назначения.
Временные регистры являются аналогом регистров SSE процессоров архитектуры x86, и поэтому вряд ли нуждаются в каких-либо комментариях. Адресный регистр хранит смещение, которое может применяться при обращении к константному регистру в режиме косвенной адресации. Например, если адресный регистр содержит значение 2, то при обращении в режиме косвенной адресации к константному регистру c5 в реальности произойдет обращение к регистру c7. В ассемблерном коде такое обращение будет выглядеть как c[a0.x + 5]. П р им еч а н ие Примитивная косвенная адресация, используемая в шейдерах, довольно сильно напоминает косвенную адресацию первых программируемых калькуляторов вроде HP-11C.
Регистры итоговых результатов Данная группа регистров используется для передачи результатов работы вершинного шейдера дальше по графическому конвейеру: сначала полученные результаты интерполируются вдоль поверхности примитива, а затем поступают на вход пиксельного шейдера. Так как первые версии вершинных и пиксельных шейдеров предполагалось применять только для визуализации примитивов с использованием незначительных вариаций классических алгоритмов, все выходные регистры Vertex Shader 1.1 являются специализированными и предназначены для хранения определенного типа данных (рисунок 5.17): Векторный регистр oPos трансформированных координат вершины (Position Register). Два векторных регистра oD0 и oD1цветов вершины (Color Registers). Изначально предполагалось, что регистр oD0 будет использоваться для хранения основного цвета вершины, а регистр oD1 – цвета блика. Восемь векторных регистров oT0, oT1, oT2, oT3, oT4, oT5, oT6, oT7 текстурных координат вершины (Texture Coordinate Register). Таким образом, с каждой вершиной может быть связано до восьми текстурных координат. Скалярный регистр oPts размера точки (Point Size Register). Используется для коррекции размера точки при визуализации массива точек (PrimitiveType.PointList). Скалярный регистр oFog, задающий плотность тумана в окрестностях данной вершины (Fog Register).
oPos
oPts
oD1
oFog
oD2
oT0 oT1 oT2 oT3 oT4 oT5 oT6 oT7 Рисунок 5.17. Выходные регистры.
Первые GPU в точности следовали спецификации Vertex Shader 1.1, поэтому их выходные регистры были жестко заточены под хранение специализированных типов данных. Соответственно, любая попытка использования данных регистров не по прямому назначению была чревата различными побочными эффектами вроде переполнения или потери точности. Но по мере развития графических процессоров данная специализация становилась всѐ более условной: все современные GPU начиная с NV4x и R5xx содержат универсальные выходные регистры, на которые отображаются выходные регистры виртуального вершинного процессора. Вероятно, вы уже обратили внимание, что названия и назначения выходных регистров удивительно напоминают семантики HLSL выходных данных вершинного шейдера. Это не случайно: исторически семантики предназначались именно для привязки выходных данных вершинного шейдера HLSL к регистрам виртуального вершинного процессора (таблица 5.3). И только потом, по мере развития GPU их функция свелась к банальной стыковке между собой выходных данных вершинных и входных данных пиксельных шейдеров. Таким образом, для написания на языке HLSL качественных шейдеров для старых GPU очень важно представлять себе архитектуру виртуального вершинного процессора и его физическую реализацию. Таблица 5.3. Соответствие между выходными регистрами вершинного процессора и семантиками HLSL Регистр
Семанитики
oPos
POSITION
oD0
COLOR, COLOR0
oD1
COLOR1
oT0
TEXCOORD, TEXCOORD0
0T1
TEXCOORD1
oT2
TEXCOORD2
oT3
TEXCOORD3
oT4
TEXCOORD4
oT5
TEXCOORD5
oT6
TEXCOORD6
oT7
TEXCOORD7
oPts
PSIZE
oFog
FOG
Возможно, сейчас у вас буквально рябит в глазах от обилия регистров вершинного процессора. В этом нет нечего страшного, ведь мы вовсе не собираемся учиться писать вершинные шейдеры на ассемблере. Нам требуется всего лишь научиться сносно читать ассемблерный код шейдера, сгенерированный компилятором HLSL, используя в качестве шпаргалки данный материал. В общем, научиться действовать по принципу “чукча не писатель, чукча читатель”.
5.3.2. Команды. Получив представление о регистрах, давайте познакомимся с форматом команд языка Vertex Shader 1.1. Если вы уже сталкивались с программированием процессоров архитектуры x86, то заметите некоторое сходство между ассемблерными командами языка Vertex Shader и командами процессоров x86. Все команды вершинного процессора имеют следующий синтаксис: op dst, src0 [, src1] [, src2]
где
op – идентификатор команды.
dst – регистр назначения, в который записываются результаты команды.
src0, src1, src2 – регистры-операнды с исходными данными. Количество операндов варьируется от
команды к команде. Важной особенностью языка Vertex Shader 1.1 является жесткое ограничение на размер вершинного шейдера: число ассемблерных команд не может превышать 128. Это не так уж и много, поэтому разработчикам нередко приходится бороться буквально за каждую команду, чтобы втиснуть алгоритм в прокрустово ложе вершинного процессора. Чтобы получить представление о функциональных возможностях виртуального вершинного процессора, рассмотрим некоторые часто используемых команды вершинных шейдеров.
MOV – Пересылка данных Начнѐм с команды пересылки из регистра в регистр, синтаксис которой очень сильно напоминает аналогичную команду процессоров семейства x86: mov dst, src
где
dst – регистр приѐмник;
src – регистр источник.
Следующая команда копирует содержимое константного регистра c2 во временный регистр r5: mov r5, c2
Язык Vertex Shader позволяет обращаться к отдельным компонентам векторного регистра: для этого после названия регистра необходимо поставить точку “.” и перечислить названия компонентов, к которым вы собираетесь обратиться. При этом допускается переставлять компоненты местами и многократно дублировать один и тот же компонент вектора. В общем, синтаксис очень напоминает синтаксис языка HLSL для доступа к отдельным компонентам вектора. Так же имеется возможность изменить знак компонентов регистра перед передачей в команду. Например, следующая команда занесет в регистр r5 лишь первые три компонента регистра c2 с измененными знаками, при этом первые два компонента будут переставлены местами: mov r5.xyz, -c2.yxz
ADD – Сложение Команда ADD выполняет сложение двух регистров: add dst, src0, src1
где
dst – регистр приемник, в который заносится результат.
src0 – регистр с первым слагаемым.
src1 – регистр со вторым слагаемым.
Действие команды можно описать выражением: dst = src0 + src1. Например, следующая команда выполняет сложение содержимого регистров v0 и c0 и заносит результат в регистр r0
add r0, v0, c0
SUB – Вычитание Данная команда выполняет вычитание двух регистров: sub dst, src0, src1
где
dst = src0 – src1
П р им еч а н ие При описании команд, смысл аргументов которых вполне очевиден, я сразу буду приводить алгоритм их работы без расшифровки назначения аргументов.
К примеру, следующая команда вычитает из компонентов x и y регистра v2 компоненты z и w регистра v3 и заносит результат в компоненты y и z регистра r1: sub r1.yz, v2.xy, v3.zw
MUL – Умножение Перемножает два регистра: mul dst, src0, src1
где
dst = src0 ∙ src1
Следующая команда умножает все компоненты регистра c0 на компонент x регистра c1 и заносит результат в регистр r0: mov r0, c0, c1.xxxx
MAD – умножение и сложение Перемножает два регистра и прибавляет к полученному результату содержимое третьего регистра: mad dst, src0, src1, src2
где dst = src0 ∙ src1 + src2 Стоит отметить, что данная команда обычно выполняется значительно быстрее комбинации команд mul и add. Ниже приведен пример умножения регистра c0 на v1 с прибавлением к результату значения вектора c1. Результат заносится в регистр r0:
mad r0, c0, v1, c1
DP3 – скалярное произведение трехмерных векторов Вычисляет скалярное произведение компонентов x, y, z двух векторов: dp3 dst, src0, src1
где
dst.xyzw = src0.x ∙ src1.x + src0.y ∙ src1.y + src0.z ∙ src1.z
Например, следующая команда занесет во все компоненты регистра r1 результат скалярного произведения первых трѐх компонентов регистров v0 и r0: dp3 r1, v0, r0
DP4 – скалярное произведение четырѐхмерных векторов Вычисляет скалярное произведение содержимого двух регистров: dp4 dst, src0, src1
где
dst.xyzw = src0.x ∙ src1.x + src0.y ∙ src1.y + src0.z ∙ src1.z + src0.w ∙ src1.w
Следующая команда занесет в первый компонент регистра r1 результат скалярного произведения всех четырех компонентов регистров v0 и r0: dp4 r1.x, v0, r0
FRC – вычисление {x} Возвращает дробную часть компонентов вектора: frc dst, src0
где
dst = {src0}
П р им еч а н ие Обратите внимание, что {2.3}=3, но {- 2.3}=0.7
Результат может быть занесен только в компоненты y или xy регистра-приемника (запись в компонент x без y недопустима). Следующая команда заносит в компоненты xy регистра r0 дробные части соответствующих компонентов регистра r1: frc r0.xy, r1
Команда frc в действительности является макрокомандой, которая разбивается на три команды. Принимая во внимание жесткие ограничения на длину вершинного шейдера, это весьма немаловажный нюанс. Многие вершинные процессоры поддерживают еѐ на аппаратном уровне, в результате чего при компиляции ассемблерного кода Vertex Shader 1.1 в микрокод вершинного процессора развернутый макрос frc может быть вновь заменен одной командой. Однако ограничение длины вершинного шейдера накладываются именно на число команд промежуточного кода Vertex Shader 1.1, поэтому даже если текущий вершинный процессор аппаратно поддерживает команду frc, при подсчете длины шейдера она всѐ равно будет засчитана за три команды.
RCP – вычисление 1/x Выполняет деление единицы на скалярный аргумент: rcp dst, src0
где
dst.xyzw=1/src0
src должен быть скалярной величиной. Если src0 равен 0, в dst заносится максимальное значение с
плавающей точкой, поддерживаемое данным GPU (обычно порядка 10 38). П р им еч а н ие GPU NVIDIA начиная с NV3x поддерживают значения Floating-Point Specials: -Inf (минус бесконечность), +Inf (бесконечность со знаком плюс), NaN (результат не определен) и т.п. Соответственно, на NV3x и последующих процессорах результат 1.0/0.0 равен +Inf. Информацию о особенностях Floating-Point Specials можно найти в [С.16].
Следующая команда вычисляет 1/r1.w и заносит результат в r0.w: rcp r0.w, r1.w
EXPP – вычисление 2x с точностью 2-3 знака после запятой Возводит 2 в степень скалярного аргумента с точностью 2-3 знака после запятой: expp dst, src0
где
dst – регистр приемник, в который заносится результат возведения в степень и побочные результаты. В компонент x заносится результат возведения в степень целочисленной части аргумента, в компонент y дробная часть аргумента, компоненту z присваивается результат возведения в степень, а компоненту w
единица.
src0 – степень, в которую возводится 2. Должна быть скалярной величиной.
Алгоритм работы80: dest.x dest.y dest.z dest.w 80
= = = =
2floor(src0) src0 – floor(src0) 2src 1
В Vertex Shader 2.0 команда EXPP претерпела серьѐзные изменения.
П р им еч а н ие Побочные результаты работы команды часто используются, например, для нахождения дробной части числа.
Значение компонента z вычисляется с точностью 10 бит (2-3 знака после запятой). Следующая команда вычисляет 2r1.w и заносит его в r0.z. Остальные компоненты регистра не изменяются благодаря использованию маски .z. rcp r0.z, r1.w
EXP – вычисление 2x с точностью 6-7 знаков после запятой Возводит 2 в степень скалярного аргумента с точностью 21 бит (6-7 знаков после запятой): expp dst, src0
где
dst.xyzw = 2src0
Данная команда в действительности является макрокомандой, транслируемой в 10 инструкций. Поэтому перед еѐ использованием следует хорошенько подумать, а действительно ли вам так сильно необходима большая точность, чем у команды expp. Следующая команда вычисляет 2r1.x и заносит его в r0.y: expp r0.y, r1.x
MIN – определение минимальных значений компонентов Выполняет покомпонентное сравнение двух аргументов и возвращает компоненты, имеющие минимальное значение. min dst, src0, src1
где dst – регистр-приемник, в который заносятся компоненты с минимальными значениями.
src0 и src1 – вектора, компоненты которых сравниваются.
Алгоритм работы: dst = src0; if (src0.x > src1.x) dst.x=src1.x; if (src0.y > src1.y) dst.y=src1.y; if (src0.z > src1.z) dst.z=src1.z; if (src0.w > src1.w) dst.w=src1.w;
Следующая команда сравнивает компоненты w регистров r4 и r0, и заносит результат в компоненты x и y регистра r3: min r3.xy, r4.w, r0
MAX – определение максимальных значений компонентов Выполняет покомпонентное сравнение двух аргументов и возвращает компоненты, имеющие максимальное значение. max dst, src0, src1
где dst – регистр-приемник, в который заносятся компоненты с минимальными значениями.
src0 и src1 – вектора, компоненты которых сравниваются.
Алгоритм работы: dst = src0; if (src0.x > src1.x) dst.x=src0.x; if (src0.y > src1.y) dst.y=src0.y;
if (src0.z > src1.z) dst.z=src0.z; if (src0.w > src1.w) dst.w=src0.w;
Следующая команда сравнивает все компоненты регистров r4 и r2, и заносит результат в регистр r1: max r1, r4, r2
SGE – сравнение «если больше или равно» Покомпонентно сравнивает содержимое двух регистров и возвращает 1, если компонент первого аргумента больше второго или равен ему, и 0 в противном случае: sge dst, src0, src1
где
dst – регистр приемник, в который заносится вектор с результатами сравнения.
src0 и src1 – вектора, компоненты которых требуется сравнить.
Алгоритм работы: dst.xyzw = 0; if (src0.x >= src1.x) dst.x = 1; if (src0.y >= src1.y) dst.y = 1; if (src0.z >= src1.z) dst.z = 1; if (src0.w >= src1.w) dst.w = 1;
SLT – сравнение «если меньше» Покомпонентно сравнивает два регистра и возвращает 1, если компонент первого аргумента меньше второго, и 0 в противном случае: slt dst, src0, src1
где
dst – регистр приемник, в который заносится вектор с результатами сравнения.
src0 и src1 – вектора, компоненты которых требуется сравнить.
Алгоритм работы: dst.xyzw = 0; if (src0.x< src1.x) dst.x = 1; if (src0.y < src1.y) dst.y = 1; if (src0.z < src1.z) dst.z = 1; if (src0.w < src1.w) dst.w = 1;
В принципе, знания вышеперечисленных команд вполне достаточно для чтения кода простых вершинных шейдеров. А при столкновении с незнакомыми командами вы всегда сможете найти их описание в документации DirectX. П р им еч а н ие Чтобы быстро найти информацию о незнакомой команде языка Vertex Shader откройте документацию по “неуправляемому” DirectX (Start | All Programs | Microsoft DirectX SDK | DirectX Documentation | DirectX SDK Documentation for C++) и введите во вкладке Index название интересующей вас команды. А для быстрого доступа к описанию всех команд и регистров интересующей вас версии Vertex Shader наберите во вкладке Index нужный идентификатор: vs_1_1, vs_2_0, vs_2_x или vs_3_0.
5.3.3. Разбираем код простого шейдера Ну что ж, настало время попрактиковаться в использовании полученных знаний на практике. В качестве упражнения мы проанализируем ассемблерный код нашего старого знакомого – эффекта вершинной закраски BlackAndWhite.fx. Чтобы облегчить поиск соответствий между вершинным шейдером на языке HLSL и его ассемблерным кодом, я ещѐ раз приведу код эффекта и листинг ассемблерного кода вершинного шейдера, полученного посредством FX Composer 2.0. Итак, код эффекта: struct VertexInput { float3 pos : POSITION; float4 color : COLOR; }; struct VertexOutput { float4 pos : POSITION; float4 color : COLOR; };
VertexOutput MainVS(VertexInput input) { VertexOutput output; output.pos = float4(input.pos, 1.0f); float luminance = (input.color.r+input.color.g+input.color.b)/3.0; output.color.r = luminance; output.color.g = luminance; output.color.b = luminance; output.color.a = input.color.a; return output; } float4 MainPS(float4 color:COLOR):COLOR { return color; } technique BlackAndWhiteFill { pass p0 { VertexShader = compile vs_1_1 MainVS(); PixelShader = compile ps_1_1 MainPS(); } }
И ассемблерный код вершинного шейдера: vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 add r0.w, v1.y, v1.x add r0.w, r0.w, v1.z mul oD0.xyz, r0.w, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy mov oD0.w, v1.w
Первая директива ассемблерного кода задает версию языка Vertex Shader, на котором написан эффект. В настоящее время поддерживаются 4 директивы c интуитивно понятными названиями, каждая из которых соответствует определенной версии языка Vertex Shader: vs_1_1, vs_2_0, vs_2_x и vs_3_0. Нетрудно догадаться, что ассемблерный код нашего шейдера написан на версии 1.1. Ниже расположена директива def, которая заносит в константный регистр c0 вектор (0.333333343, 1, 0, 0), компоненты которого будут использоваться инструкциями вершинного шейдера. Данная операция выполняется один раз перед началом визуализации с использованием шейдера и поэтому не влияет на производительность. Следующие две директивы dcl_position и dcl_color указывают, что координаты текущей вершины будут помещаться во входной регистры v0, а цвет вершины – в регистр v1. Далее начинается собственно код вершинного шейдера. Первые две команды выполняют сложение трех цветовых компонентов цвета вершины и заносят результат в компонент w регистра r0. Третья команда умножает полученную сумму на содержимое компонента x регистра c0, равного 0.333333343, то есть фактически сумма делиться на 3. Итоговый результат заносится в компоненты x, y, z выходного регистра цвета oD0. Таким образом, первые три команды вершинного шейдера соответствуют следующему коду HLSL: output.color.rgb = (input.color.r+input.color.g+input.color.b)/3.0;
Как видно, умный компилятор HLSL избавился от лишней временной переменной luminance, а так же заменил присвоение значений трем компонентам r, g, b одним скалярным присваиванием. Но заменить два сложения и унижение векторным произведением ему всѐ же не хватило сообразительности. Продолжим анализ кода вершинного шейдера. Следующая команда mad может ввести начинающего разработчика в замешательство. Откуда она взялась, ведь HLSL-код вершинного шейдера не содержит чеголибо подобного? И что же она выполняет? Давайте немного подумаем. Данная команда madd использует в качестве аргументов координаты вершины из регистра v0 и компоненты константного регистра c0, а результат заносится в выходной регистр oPos, соответствующий выходным координатам вершины. Попробуем подставить в выражение, вычисляемое командой mad значение компонентов константного регистра c0: oPos = v0.xyzx * c0.yyyz + c0.zzzy = v0.xyzx * (1, 1, 1, 0) + (0, 0, 0, 1) = (v0.xyz, 0) + (0, 0, 0, 1)
Таким образом, команда mad соответствует нижеприведенной строке HLSL-кода: output.pos = float4(input.pos, 1.0f);
Получается, компилятор нашел изящный способ реализации этого HLSL-кода: вместо прямолинейного кода из двух команд mov oPos.xyz, v0.xyz mov oPos.w, c0.y
компилятор обошелся единственной командой mad. Наконец, последняя команда mov заносит в альфа-канал выходного цветового регистра oD0 значение альфаканала цвета вершины, т.е. соответствует строке output.color.a = input.color.a
Оптимизируем вершинный шейдер Итого код шейдера насчитывает 5 инструкций, и как мы выяснили в разделе 5.2.4, его обработка на GPU NV4x и G7x обработка занимает 7 тактов. Настало время подумать, как можно улучшить производительность эффекта. Обратим внимание на два факта: 1. Компилятор HLSL не смог заменить сложение компонентов цвета с последующим делением на 3 скалярным произведением. Значит, имеет смысл попробовать переписать код эффекта с использованием встроенной в HLSL функции dot. 2. Наше приложение, использующее данный эффект, не активирует режим альфа-смешивания (alpha blending). Соответственно, оно абсолютно некритично к значению альфа-канала. Однако, как мы выяснили, присвоение значения альфа-каналу выливается в дополнительную команду. Поэтому данное присвоение можно безболезненно убрать, сократив код эффекта на одну команду. Код эффекта, написанный с учетом вышеуказанных данных рекомендацией, находится в листинге 5.5: Листинг 5.5. VertexOutput MainVS(VertexInput input) {
VertexOutput output; output.pos = float4(input.pos, 1.0f); // Значение альфа-канала не используется приложением, поэтому нам всѐ равно, что будет в него // занесено в компонент a output.color.rgba = dot(input.color.rgba, 1.0/3.0); return output; }
Ассемблерный данного эффекта, полученный посредством FX Composer 2.0, приведен ниже: // Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 vs_1_1 def c0, 0.333333343, 1, 0, 0 dcl_position v0 dcl_color v1 dp4 oD0, v1, c0.x mad oPos, v0.xyzx, c0.yyyz, c0.zzzy // approximately 2 instruction slots used
Как видно, компилятор теперь использует инструкцию скалярного произведения dp4, а инструкция mov с копированием альфа-компонента цвета исчезла. Таким образом, анализ ассемблерного кода позволил нам внести в эффект небольшие косметические преобразования и сократить размер ассемблерного кода в 2.5 раза (с 5 до 2 команд). А выполнив повторный анализ производительности эффекта (нажав кнопку Run на панели Shader Perfomance) мы увидим, что время обработки вершины одним вершинным процессором сократилось с 7 до 3-х тактов, то есть в 2.3 раза. И если раньше видеокарта GeForce 7800 GTX могла обработать за 1 секунду 491.000.000 вершит, то теперь теоретическая производительность шейдера достигла немыслимого темпа 1.146.000.000 вершин в секунду.
5.4. Передача параметров в эффект Предположим, что нам необходимо написать эффект, моделирующий визуализацию поверхности сквозь цветное стекло, пропускающее лишь часть света. Прозрачность стекла будет задаваться тремя коэффициентами, лежащими в диапазоне [0..1] и указывающими прозрачность стекла для красного, зеленого и синего компонентов цвета объекта. Если коэффициент равен 1, то стекло пропускает данный компонент цвета без изменений, если 0 – вообще не пропускает, а при промежуточных значениях 0..1 ослабляет яркость цветового компонента по мере уменьшения коэффициента. Итоговый цвет объекта определяется с использованием следующей формулы: cr = kr or cg = kg og cb = kb ob где
cr, cg, cb – итоговый цвет;
or, og, ob – исходный цвет объекта;
kr, kg, kb – коэффициент прозрачности стекла для отдельных цветов.
Данные выражения очень легко реализуются в вершинном шейдере: VertexOutput MainVS(VertexInput input) { VertexOutput output; output.pos = float4(input.pos, 1.0f); // Определяем коэффициенты пропускания разных цветов const float4 filter = float4(0.2, 0.8, 0.5, 1.0); // Вычисляем итоговый цвет вершин, используя векторную операцию умножения output.color = input.color * filter;
(5.4)
return output; }
Данный вариант отлично работает в случае одного фиксированного фильтра. Но что делать, если в процессе работы приложения фильтр постоянно меняется? Теоретически, можно попробовать динамически генерировать код шейдера, задавая значение константы filter налету. Однако такой подход имеет ряд существенных недостатков: компиляция эффекта и загрузка его в GPU занимают заметное время, что неминуемо окажет отрицательное влияние на производительность приложения. Кроме того, такие динамически генерируемые эффекты очень трудоемко сопровождать и отлаживать. Поэтому разработчики языка HLSL предусмотрели специальный механизм для быстрого внесения изменений в эффекты “налету”. Техника очень проста: если при объявлении глобальной переменной указать ключевое слово uniform, то эта переменная будет доступна и прикладной программе, использующей эффект. Например, следующий код объявляет глобальную переменную filter, значение которой будет задаваться приложением: uniform float4 circleColor;
Параметру можно указать значение по умолчанию, которое будет ему присваиваться сразу после загрузки эффекта из файла. Например: uniform float4 circleColor = float4(0.5, 1.0, 0.8, 1.0);
Впрочем, ключевое слово uniform предполагается по умолчанию, поэтому его обычно не указывают – любая глобальная переменная является uniform-переменной. Антиподом uniform является ключевое слово static, которое скрывает глобальную переменную от программы. Например: // Переменная circleColor скрыта от прикладной программы static float4 circleColor=float4(0.5, 1.0, 0.8, 1.0);
П р им еч а н ие Ключевое слово static так же применяется для объявления статических локальных переменных функции. В этом случае, его использование полностью аналогично языку C# за исключением маленького нюанса: при выходе из шейдера содержимое статических переменных теряется.
Никогда не забывайте указывать ключевое слово static для констант. Так как константы в отличие от входных параметров никогда не изменяются, это позволяет провести ряд дополнительных оптимизаций. Например, компилятор может заранее рассчитать все выражения, содержащие константы. Реализовать эффект цветного полупрозрачного стекла с использованием параметра не составит труда (листинг 5.6). Листинг 5.6. // Параметр с коэффициентами прозрачности стекла float4 filter; struct VertexInput { float3 pos : POSITION; float4 color : COLOR; }; struct VertexOutput { float4 pos : POSITION; float4 color : COLOR; };
VertexOutput MainVS(VertexInput input) { VertexOutput output; output.pos = float4(input.pos, 1.0f); output.color = input.color * filter;
return output; } float4 MainPS(float4 color:COLOR):COLOR { return color; } technique FilterFill { pass p0 { VertexShader = compile vs_1_1 MainVS(); PixelShader = compile ps_1_1 MainPS(); } }
Ниже приведен отчет NVIDIA FX Composer 2.0 с ассемблерным кодом вершинного шейдера эффекта, полученный посредством вкладки Shader Perfomance: // // // // // // // // // // // // // // // // // // //
Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 Parameters: float4 filter;
Registers: Name Reg Size ------------ ----- ---filter c0 1
Default values: filter c0 = { 0, 0, 0, 0 };
vs_1_1 def c1, 1, 0, 0, 0 dcl_position v0 dcl_color v1 mul oD0, v1, c0 mad oPos, v0.xyzx, c1.xxxy, c1.yyyx // approximately 2 instruction slots used
В комментариях перед ассемблерным кодом эффекта указано, что эффект содержит один параметр float4 filter и что компилятор отвел для хранения данного параметра константный регистр c0. При этом, так как мы не указали значение по умолчанию для данного параметра, он будет автоматически инициализироваться вектором (0, 0, 0, 0). Таким образом, изменение значения параметра filter будет сводиться к модификации значения константного регистра c0, не затрагивая собственно код вершинного шейдера. П р им еч а н ие Нетрудно догадаться, что максимальное количество параметров, принимаемых эффектом, ограничено и зависит от числа константных регистров. При этом следует помнить, что константы, используемые в эффекте, тоже неявно помещаются в константные регистры, уменьшая максимально число параметров, которые может принимать эффект.
5.4.1. Работа с параметрами эффектов в XNA Framework В XNA Framework параметры эффекта хранятся в коллекции Parameters класса Effect: public EffectParameterCollection Parameters { get; }
Доступ к элементам данной коллекции возможен как по индексу, так и по идентификатору параметра эффекта. Но на практике обычно используют второй вариант, так как он застрахован от таких непредвиденных ситуаций, как изменение числа параметров эффекта в будущих версиях эффекта: public EffectParameter this[string name] { get; }
П р им еч а н ие Если эффект не содержит параметр с указанным именем, возвращается значение null.
Собственно параметр эффекта инкапсулируется классом EffectParameter, позволяющим читать и изменять значение эффекта посредством разнообразных типизированных методов SetValue и GetValueXXX. Ниже приведены определения некоторых методов GetValueXXX. // Возвращает значение скалярного параметра HLSL типа float public float GetValueSingle(); // Возвращает значение массива параметров типа float: например, float[10]. Параметр // count указывает число элементов в массиве public float[] GetValueSingleArray(int count); // Возвращает значение параметра, являющегося двухмерным вектором (float2) public Vector2 GetValueVector2(); // Возвращает значение параметра, являющегося массивом двухмерных векторов (float2[]) public Vector2[] GetValueVector2Array(int count); // Возвращает значение параметра, являющегося трехмерным вектором (float3) public Vector3 GetValueVector3(); // Возвращает значение параметра, являющегося массивом трехмерных векторов (float3[]) public Vector3[] GetValueVector3Array(int count); // Возвращает значение параметра, являющегося четырехмерным вектором (float4) public Vector4 GetValueVector4(); // Возвращает значение параметра, являющегося массивом четырехмерным вектором (float4[]) public Vector4[] GetValueVector4Array(int count);
Такое обилие методов обусловлено тем, что с точки зрения XNA Framework параметры HLSL являются просто константными регистрами GPU, содержимое которых можно трактовать по-разному в зависимости от ситуации. Например, значение цвета rgba можно трактовать как четырехмерный вектор, два двухмерных вектора или массив из четырех скалярных элементов. П р им еч а н ие При некорректном обращении к параметру эффекта (например, при попытке записать трехмерный вектор в параметр HLSL, являющийся четырехмерным вектором) генерируется исключение System.InvalidCastException.
Методы SetValueXXX приводить не имеет смысла, так как каждому методу GetValueXXX соответствует свой метод SetValue с аналогичным набором параметров. Например, парой для метода float GetValueSingle() является метод public void SetValue(float value). В качестве примера использования параметров XNA Framework, в листинге 5.7 приведен код приложения, визуализирующего прямоугольник, видимый через цветное стекло, с возможностью изменения пользователем цвета стекла (рисунок 5.18). Визуализация осуществляется с использованием эффекта, созданного в предыдущем разделе.
Рисунок 5.18. Визуализация примитива через полупрозрачное стекло.
Листинг 5.7. public partial class MainForm : Form { // Эффект, созданный в разделе 5.4 (листинг 5.6) const string effectFileName = "Data\\FilterFill.fx"; ... Effect effect = null; // Объект, инкапсулирующий параметр filter эффекта (цвет стекла) EffectParameter filterParam; private void MainForm_Load(object sender, EventArgs e) { ... // Загружаем и компилируем эффект в промежуточный код CompiledEffect compiledEffect; compiledEffect = Effect.CompileEffectFromFile(effectFileName, null, null, CompilerOptions.None, TargetPlatform.Windows); ... // Создаем объект эффекта effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); ... // Получаем объект EffectParameter, соответствующий параметру filter filterParam = effect.Parameters["filter"]; // Если параметр filter не существует, генерируем исключение Debug.Assert(filterParam != null, effectFileName + " : не найден параметр filter"); } // Обработчик нажатия панели, открывающий на экране диалоговое окно с выбором цвета стекла private void filterPanel_Click(object sender, EventArgs e) {
if (colorDialog.ShowDialog() == DialogResult.OK) { // Изменяем цвет панели в соответствии с выбранным цветом filterPanel.BackColor = colorDialog.Color; xnaPanel.Invalidate(); } }
private void xnaPanel_Paint(object sender, PaintEventArgs e) { ... // Изменяем значение цвет стекла. Так как в Windows Form значения компонентов цвета находится // в диапазоне 0..255, а в XNA Framework в диапазоне 0..1, нам приходится делить значения // компонентов на 255 filterParam.SetValue(new Vector4((float)filterPanel.BackColor.R / 255.0f, (float)filterPanel.BackColor.G / 255.0f, (float)filterPanel.BackColor.B / 255.0f, 1.0f)); // Визуализируем прямоугольник с использование эффекта effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleStrip, vertices, 0, vertices.Length - 2); pass.End(); } effect.End(); device.Present(); ... }
В принципе XNA Framework позволяет работать с параметрами с использованием следующего лаконичного синтаксиса: effect.Parameters["filter"].SetValue(newColor);
Но, не смотря на кажущуюся простоту, эта практика весьма коварна: во-первых, увеличивается вероятность появления синтаксических ошибок в названии параметра, а во-вторых, замедляется выполнение программы, так как при каждом обращении к параметру XNA Framework вынужден выполнять поиск параметра по строке. Поэтому обработчик события Load один раз выполняется поиск параметра filter, после чего вся работа с ним осуществляется уже посредством экземпляра класса EffectParameter. Во избежание проблем при модификации приложения после получения объекта EffectParameter вызывается метод Debug.Assert с проверкой ссылки на равенство null – гораздо удобнее получить исключение при загрузке приложения рядом с “проблемным методом”, чем где-то глубоко в дебрях приложения спустя несколько минут работы.
5.5. Шейдерный фейерверк Итак, теперь вы уже знакомы с основами языков HLSL и Vertex Shader 1.1. Настало время опробовать полученные знания в более-менее сложном проекте. Ведь как гласит народная мудрость, теория без практики бесполезна, а практика без теории может быть даже вредна. В качестве отправной точки для приложения мы возьмем хранитель экрана из 4-й главы и поставим перед собой “сверхзадачу”: реализовать функциональность данного хранителя экрана, используя исключительно вершинные шейдеры. Иными словами, центральный процессор должен будет отсылать на видеокарту только команды “нарисовать диск” и “нарисовать искры”, а всю остальную работу по вращению диска и моделированию полета искр должен выполнять вершинный процессор GPU. Это весьма объемная и нетривиальная задача, поэтому мы разобьѐм еѐ на ряд более простых этапов, по мере реализации которых мы продолжим знакомиться с новыми возможностями HLSL и языка Vertex Shader 1.1.
5.5.1. Моделирование вращения диска Код хранителя экрана, выполняющий поворот диска устроен очень просто: сначала приложение вычисляет текущий угол поворота диска, а затем рассчитывает новые координаты каждой вершины диска (листинг 5.8).
Листинг 5.8. // Определяем интервал времени, прошедший с момента визуализации предыдущего кадра float delta = (float)(currentTime - lastTime); // Корректируем угол поворота диска diskAngle += diskSpeed * delta; // Рассчитываем новые координаты вершин диска diskVertices[0] = new VertexPositionColor(new Vector3(0.0f, 0.0f, 0.0f), XnaGraphics.Color.LightGray); for (int i = 0; i = 0) && (remainTime > 0)) { Vector4 color = input[j][i].Color.ToVector4(); // Определяем коэффициент прозрачности вершины color.W = remainTime / liveTime; output[j][i].Color = new XnaGraphics.Color(color); } else { output[j][i].Color = new XnaGraphics.Color(0, 0, 0, 0); } } } } }
Коротко пробежимся по основным моментам программы. Информация о вершинах теперь хранится в структуре VertexPositionColorTexture, предоставляющей помимо знакомых нам полей Position и Color ещѐ и поле TextureCoordinate, содержащее компоненты X и Y текстурных координат вершины. Выражения 5.7 и 5.8 были переписаны с использованием схемы Горнера, позволяющей сократить число
операций при вычислении степенного многочлена. Кроме того, такая запись хорошо ложится на команду mad вершинного процессора. Так же код эффекта содержит конструкцию if, делающую невидимыми искры, которые согласно логике работы приложения ещѐ не появились на экране. П р им еч а н ие Кстати, HSLS реализует вычисление разложения функций sin и cos в ряд Тейлора посредством схемы Горнера.
Перейдем к обработчику события Load, выполняющего инициализацию массивов вершин с искрами (листинг 5.15). Листинг 5.15. public partial class MainForm : Form { // Эффект для простой закраски объектов const string effectFileName = "Data\\ColorFill.fx"; const int slices = 64; const float diskSpeed = 3.0f; const float diskRadius = 0.018f; // Число искр const int fireworkVerticesCount = 300000; // Движения искр будут повторяться через каждые 20 секунд const float timeLoop = 20.0f; // Минимальная скорость вершины const float minSpeed = 0.3f; // Максимальная скорость вершины const float maxSpeed = 0.45f; // Размер искры const float pointSize = 1.0f; // Декларация формата вершины. Диск и искры в режиме эмуляции вершинного шейдера используют // общий формат вершин VertexDeclaration decl; // Массивы вершин с искрами VertexPositionColorTexture[][] fireworkVertices = null; // Массивы вершин, обработанных эмулятором вершинного шейдера VertexPositionColor[][] transformedFireworkVertices = null; // Эффект, общий для диска и искр (в режиме эмуляции) Effect effect = null; Random rnd = new Random(); Stopwatch stopwatch; bool closing = false; // Счетчики FPS // Временя, прошедшее с момента последнего вычисления количества кадров в секунду float lastTime = 0; // Число кадров, визуализированных за это время int frameCount = 0; private void MainForm_Load(object sender, EventArgs e) { // Определяем число точек, которые может визуализировать видеокарта за один присест int maxVerticesCount = Math.Min(device.GraphicsDeviceCapabilities.MaxVertexIndex, device.GraphicsDeviceCapabilities.MaxPrimitiveCount); // Определяем количество массивов вершин, которые потребуются для визуализации
// fireworkVerticesCount вершин int arrayCount = (int)Math.Ceiling((float)fireworkVerticesCount / (float)maxVerticesCount); // Создаем массивы вершин fireworkVertices = new VertexPositionColorTexture[arrayCount][]; // Создаем массивы вершин, трансформированных вершинным шейдером transformedFireworkVertices = new VertexPositionColor[arrayCount][]; // Перебираем вершины for (int k = 0; k < fireworkVerticesCount; k++) { // Определяем индекс массива вершин, соответствующего текущей вершине int j = k / maxVerticesCount; // Определяем индекс текущей вершины в массиве вершин int i = k % maxVerticesCount; // Если мы перешли к новому массиву if (i == 0) { // Определяем количество оставшихся вершин int remain = fireworkVerticesCount - j * maxVerticesCount; // Число вершин в массиве не может превышать maxVerticesCount remain = Math.Min(remain, maxVerticesCount); // Выделяем память для текущих массивов вершин fireworkVertices[j] = new VertexPositionColorTexture[remain]; transformedFireworkVertices[j] = new VertexPositionColor[remain]; } // Вычисляем время появления вершины после запуска программы fireworkVertices[j][i].Position.X = (float)rnd.NextDouble() * timeLoop; // Определяем еѐ начальное удаление от центра диска fireworkVertices[j][i].Position.Y = (float)rnd.NextDouble() * diskRadius; // Определяем начальный угол поворота вершины относительно диска fireworkVertices[j][i].Position.Z = (float)rnd.NextDouble() * 2.0f * (float)Math.PI; // Определяем начальную линейную скорость вершины fireworkVertices[j][i].TextureCoordinate.X = minSpeed + (float)rnd.NextDouble() * (maxSpeed - minSpeed); // Определяем начальную угловую скорость вершины fireworkVertices[j][i].TextureCoordinate.Y = diskSpeed / 4.0f * (1.0f + 0.01f * (float)rnd.NextDouble()); // Вычисляем цвет вершины byte red = (byte)(255 * Math.Abs(Math.Sin(fireworkVertices[j][i].Position.Z * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(fireworkVertices[j][i].Position.Z * 2))); fireworkVertices[j][i].Color = new XnaGraphics.Color(red, green, 128, 255); } } }
Чтобы иметь возможность наглядно оценить эффект переноса вычислений с CPU на GPU, мы будем визуализировать 300.000 искр. Так как ряд видеокарт (например, Intel GMA 9xx) не могут визуализировать такое количество примитивов за один присест 92, приходится автоматически разбивать массив вершин на ряд “подмассивов” меньшего размера, поддерживаемых текущей видеокартой. Так же стоит отметить, что из-за
92
См. раздел 2.4.3.
эмуляции вершинных шейдеров диска и искр, они используют общий эффект и декларацию формата вершины. Код визуализации искр является достаточно тривиальным, если не считать того факта, что искры могут храниться в разных массивах вершин (листинг 5.16). Листинг 5.16. private void MainForm_Paint(object sender, PaintEventArgs e) { // Настраиваем параметры GPU для визуализации device.RenderState.CullMode = CullMode.None; device.RenderState.AlphaBlendEnable = true; device.RenderState.BlendFunction = BlendFunction.Add; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.InverseSourceAlpha; device.RenderState.PointSize = pointSize; device.VertexDeclaration = decl; // Определяем время, прошедшее с момента запуска приложения float time = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency; // Настраиваем параметры эффекта, общие для всех вершин FireworkEffect.time = time; FireworkEffect.timeLoop = timeLoop; FireworkEffect.diskSpeed = diskSpeed; // Выполняем эмуляцию вершинного шейдера FireworkEffect.VertexShader(fireworkVertices, transformedFireworkVertices); effect.Begin(); for (int i = 0; i < effect.CurrentTechnique.Passes.Count; i++) { EffectPass currentPass = effect.CurrentTechnique.Passes[i]; currentPass.Begin(); // Перебираем все массивы вершин for (int j = 0; j < transformedFireworkVertices.Length; j++) { // Визуализируем текущий массив вершин device.DrawUserPrimitives(PrimitiveType.PointList, transformedFireworkVertices[j], 0, transformedFireworkVertices[j].Length); } currentPass.End(); } effect.End(); // Отключаем альфа-смешивание, которое не требуется при визуализации диска device.RenderState.AlphaBlendEnable = false; float angle = diskSpeed * time; // Выполняем эмуляцию вершинного шейдера диска DiskEffect.angle = angle; DiskEffect.VertexShader(diskVertices, transformedDiskVertices); // Визуализируем диск ... // Оканчиваем визуализацию кадра device.Present();
// Увеличиваем счетчик кадров frameCount++; // Если прошла одна секунда if (time - lastTime >= 1) { // Отображаем в заголовке формы текущий FPS Text = ((float)frameCount / (time - lastTime)).ToString(); // Сбрасываем счетчики lastTime = time; frameCount = 0; } }
Готовое приложение находится на CD диске в каталоге \Examples\Ch05\Ex10. Результаты тестирования на компьютерах с видеокартами NVIDIA GeForce 7600GT и Intel GMA 9xx приведены в таблице 5.6. Так как конфигурация компьютеров заметно отличается, эти данные будут использоваться не для сравнения видеокарт между собой, а исключительно для оценки прироста производительности от внедрения вершинных шейдеров. Как видно, цифры сейчас колеблются в пределах 10 кадров в секунду, что явно недостаточно для обеспечения плавной анимации. Но уверяю вас, что к концу шестой главы частота кадров будет измеряться в сотнях кадров в секунду, причем это прирост будет достигнут без какого-либо ухудшения качества изображения. Таблица 5.6. Производительность примера Ch05\Ex10 на разных GPU Конфигурация компьютера
FPS
Intel Core2 Duo E6300, i945P, 2GB RAM, DDR2-667, GeForce 7600GT 256MB, Windows Vista Ultimate x64, ForceWare 158.24
10.7
Intel Pentium-4 3.4GHz, i915P, 512MB RAM, ATI Radeon x700 Pro, Windows XP Pro SP2, ASUS Driver 8.05
6.3
Intel Core2 Duo E4300, i946GZ (GMA 3000), 2GB RAM DDR2-667, Windows Vista Ultimate x64, GMA Driver 7.14.10.1283
9.9
П р им еч а н ие Для корректного измерения производительности приложения при создании устройства свойство PresentationParameters.PresentationInterval должно быть установлено в PresentInterval.Immediate, в противном случае частота кадров будет зависеть от частоты вертикальной развертки монитора.
Вспомогательный метод загрузки эффекта из файла После выноса расчетов полета искр в вершинный шейдер, наше приложение станет использовать два эффекта. Но вот незадача: код загрузки эффекта вместе со всеми обработчиками ошибок занимает более двадцати строк. В наших предыдущих примерах, использующих не более одного эффекта, это не было существенным недостатком. Однако при загрузке двух и более эффектов громоздкий код очень негативно скажется на читаемости кода, а так же затруднит дальнейшую модификацию приложения. Это проблему можно изящно решить путѐм выноса кода загрузки эффекта в отдельный метод. Но, учитывая наши будущие приложения, будет разумнее поместить этот метод в отдельный класс Helper (листинг 5.17). Листинг 5.17. class Helper { ... // Класс исключения, которое генерируется при возникновении проблем во время загрузки эффекта
public class LoadAndCompileEffectException : Exception { public LoadAndCompileEffectException(string message) : base(message) { } } // Загружает эффект из файла и выбирает наиболее подходящую технику. При возникновении // проблем генерирует исключение LoadEffectException. public static Effect LoadAndCompileEffect(GraphicsDevice device, string filename) { CompiledEffect compiledEffect; try { compiledEffect = Effect.CompileEffectFromFile(filename, null, null, CompilerOptions.None, TargetPlatform.Windows); } catch (IOException ex) { throw new LoadAndCompileEffectException(ex.Message); } if (!compiledEffect.Success) { throw new LoadAndCompileEffectException(String.Format("Ошибка при компиляции эффекта: \r\n{0}", compiledEffect.ErrorsAndWarnings)); } Effect effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); if (!effect.CurrentTechnique.Validate()) { throw new LoadAndCompileEffectException(String.Format("Ошибка при валидации " + “техники \"{0}\" эффекта \"{1}\"\n\rСкорее всего, функциональность шейдера превышает " + “возможности GPU", effect.CurrentTechnique.Name, filename)); } return effect; } }
Полноценный эффект Имея на руках код метода-эмулятора вершинного шейдера, написанного на C# с учетом специфики языка HLSL, создание эффекта не представляет какой-либо принципиальной сложности (листинг 5.18). Тем не менее, перевод C#-кода в HLSL не должен сводиться к механической трансляции – как-никак, HLSL содержит гибкие средства для векторных вычислений, позволяющие повысить качество промежуточного ассемблерного кода. В частности, мы можем значительно сократить объем вычислений, реализовав параллельный расчет текущего расстояния вершины от центра круга и угла поворота. Листинг 5.18. // Файл Firework.fx // // Константы tSlowing и rSlowing объединены в один двухмерный вектор, что позволит // распараллелить расчет расстояния от вершины от центра и угла поворота вершины static float2 slowing = {0.105, 0.25}; static float liveTime = 4.0; float diskSpeed; float time;
float timeLoop; struct VertexInput { float3 pos : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD; }; struct VertexOutput { float4 pos : POSITION; float4 color : COLOR; };
VertexOutput MainVS(VertexInput input) { VertexOutput output; float currentTime = time - input.pos.x; float localTime = currentTime % timeLoop; float remainTime = liveTime - localTime; // Расстояние от центра диска и угол поворота вершины рассчитываются параллельно float2 t = min(localTime.xx, input.texcoord / slowing); float2 sCoord = input.pos.yz + t * (input.texcoord t * slowing / 2.0f); // Формула расчета угла поворота по сравнению с формулой расчета расстояния от центра // содержит один добавочный член sCoord.y += diskSpeed * (time - localTime); // Заменяем два умножения константы на sin и cos одним умножением на вектор (sin, cos) output.pos.xy = sCoord.x * float2(sin(sCoord.y), cos(sCoord.y)); output.pos.zw = float2(0.0, 1.0); output.color.rgb = input.color.rgb; if ((remainTime > 0) && (currentTime >= 0)) { output.color.a = remainTime / liveTime; } else { output.color.a = 0; } return output; } float4 MainPS(float4 color:COLOR):COLOR { return color; } technique Firework { pass p0
{ VertexShader = compile vs_1_1 MainVS(); PixelShader = compile ps_1_1 MainPS(); } }
Преобразование кода эффекта тоже весьма тривиально (листинг 5.19). Листинг 5.19. public partial class MainForm : Form { // Файл эффекта для визуализации вращающегося диска const string diskEffectFileName = "Data\\Disk.fx"; // Файл эффекта для визуализации разлетающихся искр const string fireworkEffectFileName = "Data\\Firework.fx"; // Эффект визуализации диска Effect diskEffect = null; // Объект, инкапсулирующий параметр angle эффекта диска EffectParameter angleParam = null; // Эффект визуализации искр Effect fireworkEffect = null; // Объекты, инкапсулирующие параметры эффекта искр: diskSpeed, time, timeLoopParam EffectParameter diskSpeedParam = null; EffectParameter timeParam = null; EffectParameter timeLoopParam = null;
private void MainForm_Load(object sender, EventArgs e) { ... // Так как вершины визуализируются без промежуточного “эмулятора”, используется “родная” // декларация формата вершин fireworkDeclaration = new VertexDeclaration(device, VertexPositionColorTexture.VertexElements); try { // Загружаем эффекты diskEffect = Helper.LoadAndCompileEffect(device, diskEffectFileName); fireworkEffect = Helper.LoadAndCompileEffect(device, fireworkEffectFileName); } catch (Helper.LoadAndCompileEffectException ex) { // Обрабатываем исключительные ситуации загрузки и компиляции эффекта closing = true; MessageBox.Show(ex.Message, "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); Application.Idle += new EventHandler(Application_Idle); return; } // Получаем объект, инкапсулирующий параметр angle эффекта диска angleParam = diskEffect.Parameters["angle"]; Debug.Assert(angleParam != null, diskEffectFileName + " : не найден параметр angle"); // Получаем объект, инкапсулирующий параметр diskSpeed эффекта искр diskSpeedParam = fireworkEffect.Parameters["diskSpeed"];
Debug.Assert(diskSpeedParam != null, fireworkEffectFileName + " : не найден параметр diskSpeed"); // Получаем объект, инкапсулирующий параметр time эффекта искр timeParam = fireworkEffect.Parameters["time"]; Debug.Assert(timeParam != null, fireworkEffectFileName + " : не найден параметр time"); // Получаем объект, инкапсулирующий параметр timeLoop эффекта искр timeLoopParam = fireworkEffect.Parameters["timeLoop"]; Debug.Assert(timeLoopParam != null, fireworkEffectFileName + " : не найден параметр timeLoop"); } private void MainForm_Paint(object sender, PaintEventArgs e) { ... float time = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency; // Задаем значения параметров timeParam.SetValue(time); timeLoopParam.SetValue(timeLoop); diskSpeedParam.SetValue(diskSpeed); // // // // //
Указывает декларацию формата вершин. Внимание! Если вы при переходе к другому формату вершин и забудете подправить декларацию формата вершины, то часть входных параметров вершины вроде текстурных координат будет содержать “мусор”. Соответственно, эффект будет работать весьма странно, а самом худшем случае это может привести к краху приложения и даже операционной системы. device.VertexDeclaration = fireworkDeclaration;
// Визуализируем искры как обычно fireworkEffect.Begin(); for (int i = 0; i < fireworkEffect.CurrentTechnique.Passes.Count; i++) { EffectPass currentPass = fireworkEffect.CurrentTechnique.Passes[i]; currentPass.Begin(); for (int j = 0; j < fireworkVertices.Length; j++) { device.DrawUserPrimitives(PrimitiveType.PointList, fireworkVertices[j], 0, fireworkVertices[j].Length); } currentPass.End(); } fireworkEffect.End();
// Выполняем приготовления к визуализации диска device.RenderState.AlphaBlendEnable = false; angleParam.SetValue(diskSpeed * time); // Не забываем изменить декларацию формата вершины device.VertexDeclaration = diskDeclaration; // Визуализируем диск ... // Вычисляем FPS
... } }
Готовое приложение находится на CD диске с книгой в каталоге Examples\Ch05\Ex11.
Анализ исходного кода эффекта Сейчас вы уже вполне неплохо освоились с языком Vertex Shader 1.1, поэтому выполнять построчный анализ ассемблерного кода вряд ли имеет смысл. Вместо этого я сразу приведу отчет NVIDIA FX Composer 2.0 с ассемблерным листингом, разделенным комментариями на блоки, соответствующие тем или иным инструкциям. // // // // // // // // // // // // // // // // // // // // // // // // // // // // //
Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 Parameters: float diskSpeed; float time; float timeLoop;
Registers: Name -----------diskSpeed time timeLoop
Reg Size ----- ---c0 1 c1 1 c2 1
Default values: diskSpeed c0 = { 0, 0, 0, 0 }; time c1
= { 0, 0, 0, 0 };
timeLoop c2 = { 0, 0, 0, 0 };
vs_1_1 def c3, 0.0416666418, -0.5, 1, 0 def c4, 4, 9.52380943, 0.104999997, 0.25 def c5, 0.5, 0.159154937, 0.25, -0.00138883968 def c6, 6.28318548, -3.14159274, -2.52398507e-007, 2.47609005e-005 dcl_position v0 dcl_color v1 dcl_texcoord v2 // float currentTime = time - input.pos.x; 1. add r2.w, -v0.x, c1.x // Начало вычисления float localTime = currentTime % timeLoop 2. mul r0.w, r2.w, c2.x 3. add r1.w, c2.x, c2.x 4. sge r0.w, r0.w, -r0.w 5. mad r0.w, r0.w, r1.w, -c2.x 6. rcp r1.w, r0.w 7. mul r3.w, r2.w, r1.w
8. 9.
expp r4.y, r3.w mov r1.w, r4.y
// Вычисление подвыражения input.texcoord / slowing из // float2 t = min(localTime.xx, input.texcoord / slowing). Деление на константу заменено // умножением 10. mul r0.xy, v2, c4.yxzw // Окончание вычисления float localTime = currentTime % timeLoop 11. mul r3.w, r0.w, r1.w // Окончание вычисления float2 t = min(localTime.xx, input.texcoord / slowing) 12. min r0.xy, r0, r3.w // float2 sCoord = 13. mul r1.xy, r0, 14. mad r1.xy, r1, 15. mad r0.xy, r0,
input.pos.yz + t * (input.texcoord c4.zwzw -c5.x, v2 r1, v0.yzzw
- t * slowing / 2.0f)
// sCoord.y += diskSpeed * (time - localTime) 16. mad r3.w, r0.w, -r1.w, c1.x 17. mad r3.w, c0.x, r3.w, r0.y // Начало вычисления output.pos.xy = sCoord.x * float2(sin(sCoord.y), cos(sCoord.y)) 18. mad r2.xy, r3.w, c5.y, c5.zxzw 19. frc r1.xy, r2 20. mad r1.xy, r1, c6.x, c6.y 21. mul r1.xy, r1, r1 22. mad r2.xy, r1, c6.z, c6.w 23. mad r2.xy, r1, r2, c5.w // Вычисление подвыражения (currentTime >= 0) оператора if 24. sge r2.w, r2.w, c3.w // Продолжение вычисления output.pos.xy = sCoord.x * float2(sin(sCoord.y), cos(sCoord.y)) 25. mad r2.xy, r1, r2, c3.x // float remainTime = liveTime - localTime 26. mad r1.w, r0.w, -r1.w, c4.x // Продолжение вычисления output.pos.xy = sCoord.x * float2(sin(sCoord.y), cos(sCoord.y)) 27. mad r2.xy, r1, r2, c3.y 28. mad r1.xy, r1, r2, c3.z // Продолжение оператора if: вычисление подвыражения (remainTime > 0) 29. slt r0.w, c3.w, r1.w // output.color.a = remainTime / liveTime 30. mul r1.w, r1.w, c4.w // Продолжение оператора if: окончание вычисления значения условия // ((remainTime > 0) && (currentTime >= 0)) 31. mul r0.w, r2.w, r0.w // Окончание вычисления выражения // output.pos.xy = sCoord.x * float2(sin(sCoord.y), cos(sCoord.y)) // (умножение на sCoord.x) 32. mul oPos.xy, r0.x, r1
// Окончание блока if. Если условное выражение блока if равно true, альфа компонент цвета // остается без изменений, иначе обнуляется. 33. mul oD0.w, r1.w, r0.w // output.pos.zw = float2(0.0, 1.0); 34. mov oPos.zw, c3.xywz // output.color.rgb = input.color.rgb 35. mov oD0.xyz, v1 // approximately 37 instruction slots used
Пробежимся по наиболее интересным местам HLSL кода. Первым сюрпризом является трансляция вычисления выражения currentTime % timeLoop аж в целых 8 инструкций (с 2-й по 9-ю). Это обусловлено тем, что язык Vertex Shader 1.1 не содержит инструкции вычисления остатка отделения, соответственно компилятору приходится эмулировать еѐ посредством скудного набора инструкций. Ниже приведена реконструкция алгоритма нахождения остатка от деления на языке C#: // Функция на языке C#, вычисляющая a%b. Написана приближенно к алгоритму, используемому в // HLSL static float mod(float a, float b) { // Если частное (a/b) является отрицательным числом, то изменяем знак у делителя (nb=-b) float cmp; if (a*b > -a*b) cmp = 1; else cmp = 0; float nb = cmp * (b + b) - b; // Вычисляем частное float div = a * (1.0f / nb); // Находим дробную часть частного float frac = div - (float)Math.Floor(div); // Вычисляем остаток float result = frac * nb; return result; }
Отдельно стоит отметить нахождение дробной части числа, до сих выполнявшаяся посредством макроса frc. Но при анализе кода нахождения остатка дизассемблер не смог распознать эту операцию, что дало нам возможность воочию увидеть, что в действительности скрывается за макросом frc. Думаю, вы ожидали увидеть здесь всѐ что угодно, только не команду expp, вычисляющую приближенное значение 2n. Правда компилятора интересует не само значение 2n, а побочный результат команды, заносящей в компонент y вектора-результата дробную часть числа (a – floor(a)). В целом же из всего вышесказанного следует вывод, что, несмотря на обманчиво простой вид, оператор % языка HLSL является очень “дорогой” операцией, соизмеримой по времени выполнения с вычислением тригонометрических функций. Чтобы максимально задействовать суперскалярную архитектуру современных вершинных процессоров, компилятор HLSL изменил их порядок следования, чтобы избавиться от зависимости соседних инструкций. Обратной стороной медали является сложность анализа кода: инструкции многих операторов HLSL перемешались между собой, а код строки float remainTime = liveTime – localTime, расположенной в начале эффекта, был перенесен компилятором ближе к концу шейдера. Ещѐ одной любопытной особенностью является код оператора if, составное условное выражение которого содержит логическую операцию “и” – так как язык Vertex Shader 1.1 не поддерживает булевские типы и логические операции над ними, оператор && эмулируется перемножением чисел с плавающей точкой.
Оптимизация вершинного шейдера И, наконец, анализируя код строки float2 sCoord = input.pos.yz + t * (input.texcoord t * slowing / 2.0f) мы обнаружим, что компилятор не смог догадаться предварительно вычислить
значение константы slowing / 2.0f, что вылилось в один лишний оператор mul. Это дает нам основание предположить, что добавив в файл HLSL явное вычисление константы, мы сможем немного ускорить работу приложения. Но наверняка быть уверенным нельзя, ведь Vertex Shader 1.1 является всего лишь промежуточным кодом, впоследствии ещѐ раз оптимизируемым компилятором драйвера. Ну что ж, рискнем. Основные фрагменты эффекта с модифицированным вершинным шейдером приведены в листинге 5.20. Листинг 5.20. // Полный текст эффекта находится на CD с книгой в каталоге Examples\Ch05\Ex12 static float2 slowing = {0.105, 0.25}; // Явно рассчитываем значение вспомогательной константы static float2 slowing2 = slowing / 2.0f; ... VertexOutput MainVS(VertexInput input) { ... float2 sCoord = input.pos.yz + t * (input.texcoord t * slowing2); ... }
-
Просмотр ассемблерного кода приложения даст вполне предсказуемые результаты: число команд ассемблерного листинга уменьшилось на одну (с 37 до 36), а вот время выполнения эффекта сократилось на целых 5 тактов (с 42 до 37) – вероятно удаление одной лишней команды позволило драйверу более эффективно распараллелить выполнение команд вершинного шейдера. В результате пиковая производительность эффекта на NVIDIA GeForce 7800 GTX увеличилась с 76.000.000 до 86.000.000 вершин в секунду, т.е. на 13%. Таким образом, даже незначительные изменения в коде эффекта могут спровоцировать лавину изменений в финальном микрокоде шейдера для физического вершинного процессора, которые могут как усилить эффект от оптимизации HLSL-кода шейдера, так и свести еѐ на нет и даже снизить производительность.
5.5.4. Анализ производительности приложения Настало время оценить эффект от переноса вычислений на видеокарту. На рисунке 5.23 приведена диаграмма, построенная в Excel по результатам измерения производительности примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU. 90 80 70 60 50 Ex10 (CPU) 40
Ex12 (GPU)
30 20 10 0 GeForce7600GT
Radeon X700 Pro
i946GZ (GMA 3000)
Рисунок 5.23. Производительность примеров Ch05\Ex10 и Ch05\Ex12 на разных GPU.
Как видно, на GeForce 7600GT и Radeon X700 Pro перенос вычислений с CPU на GPU увеличил частоту кадров почти в 10 раз. На компьютере с интегрированным GPU i946GZ (GMA 3000) частота кадров тоже заметно увеличилась (в 3.5 раза), что на первый взгляд выглядит весьма странно: i946GZ не содержит аппаратного вершинного процессора93, поэтому все вычисления по-прежнему выполняются силами центрального процессора. Данный парадокс обусловлен рядом факторов. Как известно, .NET приложения содержат множество вспомогательного кода для обнаружения различных внештатных ситуаций вроде переполнения или обращения к несуществующему элементу коллекции. Разумеется, этот код оказывает отрицательное влияние на производительность, усугубляемое многократным его выполнением в цикле. Кроме того, все современные процессоры ещѐ со времен Pentium-III содержат специализированный векторные регистры SSE и набор векторных инструкций, отдаленно напоминающие ассемблерные команды языков Vertex Shader. Но язык C# и промежуточный язык IL не содержат векторных команд, что затрудняет распознавание векторных операций при компиляции JIT-компилятором IL-кода exe-файла в машинный код. В результате, итоговый машинный код практически не содержит SSE-инструкций и векторные блоки центрального процессора фактически простаивают. При использовании вершинных шейдеров всѐ обстоит несколько иначе. На i946GZ и аналогичных GPU без аппаратных вершинных процессоров вершинные шейдеры эмулируются DirectX посредством специальной подсистемы Processor Specific Geometry Pipeline (PSGP). PSGP автоматически выполняет компиляцию вершинного шейдера в набор инструкций текущего CPU, задействовав весь потенциал данного процессора на 100%. Полученный код активно использует блоки SSE, параллельную обработку нескольких вершин всеми ядрами CPU и не содержит каких-либо ненужных промежуточных проверок “на всякий случай”. В результате он работает заметно быстрее по сравнению с аналогом на C#, что мы и наблюдаем. Итак, вершинные шейдеры позволяют значительно поднять производительность приложения. Но не стоит забывать, что это упреждение верно лишь при сравнении производительности C# и HLSL-кода, использующего одинаковый алгоритм. Центральный процессор предоставляет разработчику использовать значительно более гибкие алгоритмы, так что на практике все обстоит несколько сложнее. Но в любом случае, вершинные шейдеры позволяют разгрузить центральный процессор, освободив его ресурсы для других задач.
Заключение В этой главе мы познакомились с новыми возможностями языка HLSL применительно к программированию вершинных шейдеров: работе с отдельными компонентами вектора, математическими операторами, встроенными функциями, параметрами эффекта и особенностями оператора if. Так же была рассмотрена IDE для разработки шейдеров NVIDIA FX Composer 2.0, которая, учитывая рост сложности наших эффектов, пришлась как нельзя кстати. Учитывая, что вершинный шейдер выполняется для каждой вершины, число которых может измеряться сотнями тысяч, очень важно уделять внимание качеству кода и оптимизации вершинного шейдера. А для этого очень полезно иметь хотя бы поверхностное представление о том, что твориться под капотом HLSL, в частности о языках Vertex Shader. Поэтому мы изучили основы архитектуры виртуального процессора Vertex Shader 1.1 и его систему команд.
93
Если быть более точным, в чипсет Intel 946GZ интегрирован GPU Intel GMA 3000, содержащий аппаратные вершинные процессоры. Однако в текущей версии драйверов (7.14.10.1283) вершинные процессоры отключены, так что на Intel 946GZ вершинные шейдеры пока эмулируются программно средствами CPU.
Заключение Ну, вот и всѐ. Надеюсь, книга принесла вам пользу и удовольствие, но возможно у вас появились и замечания. Обо всех найденных ошибках и опечатках обязательно сообщайте на мой email:
[email protected]. Не стоит забывать, что данная книга является лишь вводным руководством в мир XNA. В частности, в этой книге не рассмотрены такие важные темы, как аффинные преобразования, визуализация 3D объектов, конвейер контента XNA. Тем не менее, после прочтения этой книги вы легко смежите продолжить самостоятельное изучение этих тем. Ниже приведены несколько полезных ссылок на Internet-сайты, содержащие огромное количество информации по XNA и связанным с ней темам: http://xna.com/ – официальный ресурс по XNA. Настоятельно советую посетить http://creators.xna.com/ , содержащий огромное количество уроков, примеров и статей по XNA.
раздел
http://msdn2.microsoft.com/en-us/xna/default.aspx – раздел по XNA в MSDN. http://forums.microsoft.com/msdn/ - тематические форумы, в том числе и по XNA. http://abi.exdream.com/ - домашняя страница Benjamin Nitschke, автора книги Professional XNA Game Programming и множества известных Starter Kit-ов. http://www.xnadev.ru/ – российский ресурс, посвященный XNA.