Оглавление Введение ...................................................................................................................................................... 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 <= LineStripCount; i++) { // Вычисляем координаты текущей вершины окружности по формуле 2.2 float angle = (float)i / (float)LineStripCount * 2.0f * (float)Math.PI; // Окружность имеет радиус 0.8 единиц и расположена в начале системы координат float x = 0.8f * (float)Math.Sin(angle); float y = 0.8f * (float)Math.Cos(angle); // Вычисляем цвет вершины int red=(int) (255 * Math.Abs(Math.Sin(angle * 3))); int green = (int)(255 * Math.Abs(Math.Cos(angle * 2))); // Заносим информацию о вершине в графический буфер vertices[i] = new VertexPositionColor(new Vector3(x, y, 1.0f), new XnaGraphics.Color(red, green, 0)); }; // Рисуем ломанную, аппроксимирующую окружность. Ломанная состоит из vertices.Length - 1 // сегментов. effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.LineStrip, vertices, 0, vertices.Length - 1); pass.End(); } effect.End(); device.Present(); } ... }
Приложение устроено достаточно просто: сначала в обработчике события Load по формуле 2.2 вычисляются вершины, через которые будет построена ломаная, аппроксимирующая окружность. Визуализация полученной ломанной выполняется в обработчике события Paint. Стоит отметить, что с ростом числа сегментов ломанная все сильнее начинает походить на настоящую окружность; при количестве сегментов порядка сотни вряд ли кто сможет найти визуальные различия между окружностью, визуализированной поточено средствами GDI+, и еѐ аппроксимацией ломанной линией. А вот разница в производительности будет более чем заметна.
Управление видовым преобразованием. Так как мы используем логическую систему координат, в которой ширина и высота формы всегда равна двум, непропорциональное растяжение формы приводит к искажению изображения (рисунок 2.17). Существует два метода борьбы с этим явлением: 17. “Ручное” масштабирование примитивов таким образом, чтобы они всегда корректно отображалось независимо от размеров формы. 18. Запретить пользователю делать окно неквадратным, то есть действовать по принципу “нет неквадратной формы – нет и проблемы”.
Рисунок 2.17. Искажения формы круга при непропорциональном изменении размеров окна.
У каждого из этих подходов есть недостатки: реализация масштабирования примитивов сцены неминуемо сделает код визуализации более запутанным, а ограничение на размеры окна будет сковывать действия пользователя и создаст чувство дискомфорта. Однако существует и третий вариант: 19. Автоматическое изменение размера непропорционального размера формы.
визуализируемого
изображения
для
компенсации
Этот метод имеет важный нюанс относительно первого варианта: в данном случае трансформируются не координаты вершин примитива, а итоговое изображение40. В разделе 2.3.1 на рисунке 2.2 была приведена схема графического конвейера. Как вы знаете, на выходе из вершинного шейдера получаются вершины в логической системе координат, в которой координаты вершин лежат в диапазоне [-1, +1]. Затем эти логические координаты трансформируются в систему координат окна посредством видового преобразование. Параметры этого преобразования задаются свойством Viewport класса GraphicsDevice: public Viewport Viewport { get; set; }
Одноименная структура Viewport, инкапсулирующая параметры видового преобразования, определяется следующим образом: public struct Viewport { // Ширина области, в которую осуществляется визуализация public int Width { get; set; } // Высота области, в которую осуществляется визуализация public int Height { get; set; } // Координата X левого верхнего угла области визуализация public int X { get; set; } // Координата Y левого верхнего угла области визуализация public int Y { get; set; } ... }
Как видно, структура Viewport определяет в клиентской области окна прямоугольную область, используемую для визуализации изображения. По умолчанию при создании и сбросе устройства XNA Framework автоматически присваивает полям X и Y нулевое значение, а Width и Height – ширину и высоту клиентской области окна. Таким образом, по умолчанию клиентская область заполняет всю клиентскую область окна. В следующем примере демонстрируется использование пользовательской области визуализации размером 100×100, расположенной в левом верхнем углу приложения (листинг 2.19). Листинг 2.19. 40
Физически этот метод всѐ же сводится к неявной модификации координат вершин после трансформации вершинным шейдером.
// Пример Examples\Ch02\Ex09 private void MainForm_Load(object sender, EventArgs e) { // Важно! Размер формы не может быть меньше области визуализации, в противном случае при // попытке визуализации в такую форму будет сгенерировано исключение // System.InvalidOperationException MinimumSize = SizeFromClientSize(new Size(100, 100)); ... } private void MainForm_Paint(object sender, PaintEventArgs e) { if (closing) return; try { if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.Lost) throw new DeviceLostException(); if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.NotReset) device.Reset(); // Задание параметров области визуализации. Viewport viewport = new Viewport(); viewport.Width = 100; viewport.Height = 100; // Присваиваем информацию об области визуализации структуре Viewport device.Viewport = viewport; // Очищаем экран device.Clear(XnaGraphics.Color.CornflowerBlue); // Рисуем окружность ... } }
Результат визуализации приведен на рисунке 2.18. Что же изменилось в работе приложения? 20. Размер визуализируемого изображения теперь не зависит от размера окна. Соответственно, непропорциональный размер формы больше не искажает изображение. 21. Клиентская область формы за пределами области визуализации заполнена “мусором”. В этом не ничего удивительного, ведь наше приложение оставляет содержимое формы за пределами области визуализации “как есть”.
Рисунок 2.18. Использование области визуализации 100×100, расположенной в левом верхнем углу окна.
П р им еч а н ие Отладочная версия DirectX перед началом визуализации кадра автоматически закрашивает его случайным цветом, что позволяет разработчику легко обнаружить области окна, игнорируемые приложением.
Теперь мы уже можем определиться со стратегией борьбы с геометрическими искажениями в примере Ex08: перед визуализацией изображения приложение должно задать квадратную область визуализации максимально возможного размера, расположенную в клиентской области формы. При этом, во избежание артефактов по краям окна, во время очистке окна методом Clear должна применяться область визуализации размеров во всю клиентскую область формы. Учитывая, универсальность подобной технологии, методы рассчитывающие параметры области визуализации будет разумно поместить в наш класс Helper.cs (листинг 2.20). Листинг 2.20. // Сборник вспомогательных методов, полезных в хозяйстве class Helper { ... // Принимает размеры клиентской области формы. Возвращает квадратную область визуализации // максимально возможного размера, расположенную в центре формы. public static Viewport SquareViewport(System.Drawing.Size clientSize) { Viewport viewport = new Viewport(); viewport.Width = Math.Min(clientSize.Width, clientSize.Height); viewport.Height = viewport.Width; viewport.X = (clientSize.Width- viewport.Width) / 2; viewport.Y = (clientSize.Height - viewport.Height) / 2; return viewport; } // Принимает размеры клиентской области формы. Возвращает область визуализации размером во // всю клиентскую область формы public static Viewport FullScreenViewport(System.Drawing.Size clientSize) { Viewport viewport = new Viewport(); viewport.Width = clientSize.Width; viewport.Height = clientSize.Height; return viewport;
} }
Код модифицированного приложения, использующего новые методы класса Helper, приведен в листинге 2.21. Листинг 2.21. // Пример Ch02\Ex10 private void MainForm_Paint(object sender, PaintEventArgs e) { if (closing) return; try { if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.Lost) throw new DeviceLostException(); if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.NotReset) device.Reset(); // Используем для визуализации всю клиентскую область окна device.Viewport = Helper.FullScreenViewport(ClientSize); // Очищаем экран device.Clear(XnaGraphics.Color.CornflowerBlue); // Используем квадратную область визуализации device.Viewport = Helper.SquareViewport(ClientSize); ... } }
Результат визуализации круга в окне с неквадратной клиентской областью приведен на рисунке 2.19. Как видно, несмотря на непропорциональное изменение сторон окна, круг остался кругом.
Рисунок 2.19. Круг, визуализированный с использованием квадратной области визуализации, расположенной в центре формы.
Практическое упражнение №2.2. Доработайте приложение, визуализирующее график косинуса из практического упражнения №2.1, заменив примитивы PrimitiveType.PointList на PrimitiveType.LineStrip. Так же добавьте в приложение высотную закраску: когда функция принимает значение 1, график должен окрашиваться зелѐным цветом, а при -1 – красным. Остальные точки графика принимают промежуточные цвета (рисунок 2.20). Для вычисления цвета промежуточных точек можно воспользоваться следующими формулами:
red (0.5 0.5 cos )0.3 255
(2.3)
green (0.5 0.5 cos )0.3 255 где
red – красная составляющая цвета green – зелѐная составляющая цвета П р им еч а н ие Если же убрать операцию возведения в степень, то график функции будет заметно терять яркость в окрестностях y равного 0, что смотрится не особо красиво.
Рисунок 2.20. График синуса с высотной закраской
Готовое приложение можно найти на CD с книгой в каталоге Examples\Ch02\Ex11.
2.6. Треугольники Для визуализации наборов треугольников с различной топологией в XNA Framework имеется три типа примитивов: PrimitiveType.TriangleList, PrimitiveType.TriangleFan и PrimitiveType.TriangleStrip. Начнѐм с самого простого примитива, PrimitiveType.TriangleList.
2.6.1. Несвязанные треугольники (PrimitiveType.TriangleList) Этот примитив предназначен для визуализации набора несвязанных треугольников: первый треугольник строится с использованием 0-й, 1-й и 2-й вершин, второй треугольник – 3-й, 4-й и 5-й вершин, третий треугольник – 6-й, 7-й и 8-й вершин и т.д. (рисунок 2.21). v1
v5
v4
v7
v0 v2 v3
v6
v8
Рисунок 2.21. Треугольники, нарисованные с использованием примитива PrimitiveType.TriangleList.
По умолчанию XNA Framework отображает на экране только те треугольники, вершины которых расположены на экране почасовой стрелки. К примеру, при визуализации треугольников, изображѐнных на рисунке 2.21 на экране отобразятся только крайние треугольники (v0, v1, v2) и (v6, v7, v8). А вот средний треугольник (v3, v4, v5) будет отброшен, так как его вершины перечисляются против часовой стрелки. Такое на первый взгляд странное поведение XNA Framework обусловлено особенностью отсечения невидимых треугольников в трехмерных сценах. Однако при визуализации двухмерных изображений эта
функциональность оказывается не только излишней, но и вредной. Поэтому разработчики XNA Framework заботливо предусмотрели свойство GraphicsDevice.RenderState.CullMode, управляющее режимами отсечения треугольников: public CullMode CullMode { get; set; }
Это свойство может принимать следующие значения перечислимого типа CullMode: CullMode.None – отсечение выключено CullMode.Clockwise – отсекаются треугольники, вершины которых расположены на экране по часовой стрелке CullMode.CounterClockwise – отсекаются треугольники, у которых вершины расположены на экране против часовой стрелки. По
умолчанию свойству GraphicsDevice.RenderState.CullMode присваивается значение CullMode.CounterClockwise, то есть видеокарта отбрасывает все треугольники, у которых вершины расположены против часовой стрелки. Для отключения этой функциональности достаточно присвоить этому свойству значения CullMode.None. В листинге 2.22 приведѐн исходный код основных фрагментов программы (Ex12), рисующей в центре экрана треугольник (рисунок 2.22).
Рисунок 2.22. Треугольник с разноцветными вершинами
Листинг 2.22 public partial class MainForm : Form { const string effectFileName = "Data\\ColorFill.fx"; GraphicsDevice device = null; PresentationParameters presentParams; Effect effect = null; VertexDeclaration decl = null; VertexPositionColor[] vertices = null;
FillMode fillMode=FillMode.Solid; bool closing = false; ... private void MainForm_Load(object sender, EventArgs e) { ... // Создаѐм массив для хранения трѐх вершин треугольника vertices = new GraphicsBuffer(3); // Задаѐм вершины треугольника vertices[0] = new VertexPositionColor(new Vector3(0.0f, 0.4f, 0.0f), XnaGraphics.Color.Coral); vertices[1] = new VertexPositionColor(new Vector3(0.4f, -0.4f, 0.0f), XnaGraphics.Color.LightGreen); vertices[2] = new VertexPositionColor(new Vector3(-0.4f, -0.4f, 0.0f), XnaGraphics.Color.Yellow); } private void MainForm_Paint(object sender, PaintEventArgs e) { ... device.Clear(XnaGraphics.Color.CornflowerBlue); // Выключаем отсечение треугольников device.RenderState.CullMode = Cull.None; device.VertexDeclaration = decl; // Рисуем треугольник effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleList, vertices, 0, vertices.Length / 3); pass.End(); } effect.End(); device.Present(); ... } ...
Как видно, листинг программы мало чем отличается от предыдущих примеров. Единственное разница заключается в отключении режима отсечения треугольников и использовании примитивов типа PrimitiveType.TriangleList.
Режимы закраски Как известно, многие приложения 3D моделирования вроде 3ds Max или Maya позволяют отображать сцену в режиме проволочного каркаса (Wireframe). Благодаря этому разработчик может ясно видеть топологию сцены, в частности, взаимное расположение всех треугольников на сцене. XNA Framework тоже поддерживает подобную функциональность, позволяя отображать вместо закрашенных треугольников их проволочный каркас. Управление этой функциональностью осуществляется при помощи свойства RenderState.FillMode класса GraphicsDevice: FillMode FillMode { get; set; }
Свойство может принимать следующие значения перечислимого типа FillMode:
FillMode.Point – визуализируются только точки, расположенные вершинах треугольника. Визуализируемые точки являются полноценными точками XNA Framework: к примеру, их размер можно изменять при помощи свойства device.RenderState.PointSize. FillMode.WireFrame – визуализирует каркас треугольника, который рисуется с использованием обычных линий вроде TrianglePrimitive.LineList или TrianglePrimitive.LineStrip. FillMode.Solid – закрашивает внутреннюю область треугольника. По умолчанию свойству RenderState.FillMode присвоено значение FillMode.Solid, то есть треугольники рисуются закрашенными. Для демонстрации практического использования свойства FillMode мы добавим в нашу программу (Ex12) возможность циклической смены режимов отображения треугольников при помощи клавиши пробел (листинг 2.23). Листинг 2.23 public partial class MainForm : Form { // Режим отображения треугольников FillMode fillMode=FillMode.Solid; ... private void MainForm_Paint(object sender, PaintEventArgs e) { ... // Выключаем отсечение треугольников device.RenderState.CullMode = Cull.None; // Задаѐм режим отображения треугольников device.RenderState.FillMode = fillMode; // Задаѐм размер точек device.RenderState.PointSize = 3.0f; ... // Рисуем треугольник effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleList, vertices, 0, verteces.Length / 3); pass.End(); } effect.End(); ... } private void MainForm_KeyDown(object sender, KeyEventArgs e) { // Если нажата клавиша пробел if (e.KeyCode == Keys.Space) { // Изменяем режим отображения switch (fillMode) { case FillMode.Point: fillMode = FillMode.WireFrame; break; case FillMode.WireFrame: fillMode = FillMode.Solid; break; case FillMode.Solid: fillMode = FillMode.Point;
break; } // Перерисовываем экран Invalidate(); } } }
Узор Серпинского. Перейдѐм к более сложному примеру. Наше следующее приложение будет строить узор Серпинского41 путѐм рекурсивного разбиения треугольника, визуализируемого в каркасном режиме. Построение узора начинается с базового треугольника (рисунок 2.23). На первой интеграции в данный большой треугольник вписывается другой треугольник меньшего размера, вершины которого расположены в середине сторон большого треугольника (рисунок 2.24). В результате большой треугольник оказывается как бы разбит на 4 треугольника. На втором этапе данные в три треугольника, примыкающие к вершинам исходного большого треугольника, вписываются по три треугольника (рисунок 2.25). На третьем этапе в образовавшиеся девять треугольников вписываются уже девять треугольников (рисунок 2.26), на четвертом этапе вписывается уже 27 треугольников и так далее. В идеале процесс должен продолжаться до бесконечности, однако на практике вполне можно ограничиться десятком итераций (рисунок 2.27), так как размер треугольников, генерируемых в последующих итерациях, будет уже меньше размера пикселей экрана.
Рисунок 2.23. Построение узора Серпинского. Базовый треугольник.
41
Узор Серпинского, являющийся фракталом, подробно описан [16]
Рисунок 2.24. Построение узора Серпинского. Первая итерация.
Рисунок 2.25. Построение узора Серпинского. Вторая итерация.
Рисунок 2.26. Построение узора Серпинского. Третья итерация.
Рисунок 2.27. Узор Серпинского, 11 итераций.
Так как приложение будет визуализировать десятки или даже сотни тысяч треугольников, очень важно поместить их в единый массив и вывести одним вызовом метода DrawUserPrimitives. Однако для создания такого массива очень полезно заранее знать количество треугольников, которые будут визуализированы за n итераций. Это поможет нам избежать многочисленных изменений размера массива по мере генерации треугольников. Давайте попробуем найти зависимость числа визуализируемых треугольников от количества интеграций. И так, при нулевом количестве итераций мы визуализируем 1 треугольник. При одной итерации число треугольников становится 1 + 1 = 2. При двух итерациях количество треугольников будет равно 1 + 1 + 3 = 5, при трех 1 + 1 + 3 + 9 = 14. Таким образом, мы можем вывести некоторую общую закономерность для n итераций: n1
tc 1 1 3 9 ... 3n1 1 3i
(2.4)
i 0
где
tc – количество треугольников, визуализируемых при n итераций.
В принципе, это выражение вполне приемлемо, однако знак суммы смотрится не особо красиво. Однако открыв учебник высшей математики вроде [К.20] можно найти весьма интересное соотношение: n
xi i 0
1 x n1 1 x
(2.5)
Соответственно, выражение 2.4 можно переписать без использования n элементов: n1
tc 1 3i 1 i 0
1 3( n1)1 3n 1 1 3 2
(2.6)
Гораздо более наглядное выражение, не так ли? Однако так как разные видеокарты могут визуализировать разное число треугольников, не исключено, что приложению придется решать и образную задачу. Допустим, мы определим в приложении число итераций (n) равным 11, то есть узор Серпинского будет содержать
311 1 88574 треугольников с общим количеством вершин 88574 ∙ 3 = 265722. Но ведь 2
некоторые видеокарты могут оказаться не способными визуализировать такое количество треугольников за один присест. Как приложение должно повести себя в подобном случае? Наиболее простое решение – сократить количество интеграций до максимально приемлемого. А для этого нам придется определять максимальное количество итераций (n), при котором количество треугольников не превышает заданное значение tc. Для этого выражение (2.6) достаточно переписать как
3n 2 tc 1
(2.7)
после чего взять от обоих частей выражения логарифм по основанию 3:
n floor (log 3 (2 tc 1))
(2.8)
где
floor(x) – функция, возвращающая целое число, не превышающее x (то аналог метода Math.Floor из C#). К сл о ву Согласно выражению 2.8 на компьютере с Intel GMA 900 приложение может выполнить до 9-ти итераций, на NVIDIA NV2x-3x до 12-ти итераций, а на ATI R2xx–R5xx до 13-ти итераций.
После такого небольшого математического экскурса можно приступать реализации нашего приложения. Визуализация треугольников будет осуществляться в два этапа: 1.
Заполнение графического буфера информацией о треугольниках.
2.
Визуализация треугольников одним вызовом метода DrawUserPrimitives.
Вычисление
координат
треугольников
мы
организуем
с
использованием
рекурсивной
функции
DrawTriangle принимающей в качестве параметров координаты треугольника и количество оставшихся
итераций. Эта функция будет помещать в массив вершин координаты текущего треугольника, после чего выполнять деление этого треугольника на три части и вызывать саму себя для этих частей, но уже с уменьшенным количеством оставшихся итераций на 1. Процесс повторяется до тех пор, пока количество оставшихся итераций не достигнет 0. Исходный код основных фрагментов программы с подробными комментариями приведѐн в листинге 2.24. Листинг 2.24. // Примем Examples\Ch02\Ex13 public partial class MainForm : Form { // Число итераций для визуализации треугольника Серпинского. Если видеокарта не способна // визуализировать такое количество треугольников, число итераций автоматически уменьшается // до приемлемого значения const int n = 15; const string effectFileName = "Data\\ColorFill.fx"; GraphicsDevice device = null; PresentParameters presentParams; Effect effect = null; VertexDeclaration decl = null; // Массив вершин узора Серпинского VertexPositionColor[] vertices = null; // Индекс текущей вершины (глобальная переменная, используемая при рекурсивном формировании // узора Серпинского) int currentVertex; // Рекурсивная функция, заносящая в массив vertices информацию о вершинах узора. // a, b, c – координаты текущего треугольника // pass – число оставшихся итераций void DrawTriangle(Vector2 a, Vector2 b, Vector2 c, int pass) { // Если это последняя итерация, выходим из функции if (pass <= 0) return; // Уменьшаем количество оставшихся итераций pass -= 1; // Помещаем в массив вершины треугольника вписанного в текущий “большой” треугольник vertices[currentVertex] = new VertexPositionColor(new Vector3(ab.X, ab.Y, 0.0f), XnaGraphics.Color.Black); vertices[currentVertex + 1] = new VertexPositionColor(new Vector3(ac.X, ac.Y, 0.0f), XnaGraphics.Color.Black); vertices[currentVertex + 2] = new VertexPositionColor(new Vector3(bc.X, bc.Y, 0.0f), XnaGraphics.Color.Black);
// Увеличиваем индекс текущей вершины currentVertex += 3; // Вычисляем координаты середины сторон Vector2 ab = new Vector2((a.X + Vector2 ac = new Vector2((a.X + Vector2 bc = new Vector2((b.X +
треугольника b.X) / 2.0f, (a.Y + b.Y) / 2.0f); c.X) / 2.0f, (a.Y + c.Y) / 2.0f); c.X) / 2.0f, (b.Y + c.Y) / 2.0f);
// Вызываем этот рекурсивный метод для образовавшихся трех крайних треугольников, примыкающих // к углам текущего треугольника DrawTriangle(a, ab, ac, pass); DrawTriangle(b, ab, bc, pass); DrawTriangle(c, ac, bc, pass); } private void MainForm_Load(object sender, EventArgs e) { ... // Определяем максимальное количество треугольников, которое текущая видеокарта может // визуализировать за один присест int maxTriangleCount = Math.Min(device.GraphicsDeviceCapabilities.MaxPrimitiveCount, device.GraphicsDeviceCapabilities.MaxVertexIndex / 3); // Вычисляем по формуле 2.8 максимальное количество итераций визуализации узора, которые // можно выполнить на текущей видеокарте int maxPass = (int) Math.Floor(Math.Log(2 * maxTriangleCount - 1, 3)); // При необходимости уменьшаем количество интеграций, которое задаются константой n int passes = Math.Min(n, maxPass); // Вычисляем по формуле 2.6 количество треугольников, формирующих данный узор Серпинского. int triangleCount = ((int)Math.Pow(3, passes) + 1) / 2; // Выделяем память для хранения информации о вершинах треугольниках vertices = new VertexPositionColor[3 * triangleCount]; Text += " Количество итераций: " + passes.ToString(); // Вершины начального треугольника Vector2 a = new Vector2(0.0f, 0.9f); Vector2 b = new Vector2(-0.9f, -0.9f); Vector2 c = new Vector2(0.9f, -0.9f); // Обнуляем индекс текущей вершины currentVertex = 0; // Заносим в массив вершины самого большого треугольника vertices[currentVertex] = new VertexPositionColor(new Vector3(a.X, a.Y, 0.0f), XnaGraphics.Color.Black); vertices[currentVertex + 1] = new VertexPositionColor(new Vector3(b.X, b.Y, 0.0f), XnaGraphics.Color.Black); vertices[currentVertex + 2] = new VertexPositionColor(new Vector3(c.X, c.Y, 0.0f), XnaGraphics.Color.Black); currentVertex += 3; // Выполняет рекурсивное деление треугольника в течении pass итераций CreateTriangle(a, b, c, passes); } private void MainForm_Paint(object sender, PaintEventArgs e) { ...
// Очищаем экран device.Clear(ClearFlags.Target, Color.White, 0.0f, 0); device.BeginScene(); // Отключаем отсечение треугольников device.RenderState.CullMode = Cull.None; // Используем каркасную визуализацию треугольников device.RenderState.FillMode = FillMode.WireFrame; device.VertexFormat = TransformedColored.Format; // Рисуем треугольники device.DrawUserPrimitives(PrimitiveType.TriangleList, verteces.NumberElements/3, verteces); device.EndScene(); device.Present(); } ... } }
Практическое упражнение №2.3. Напишите приложение, рисующее обыкновенный деревянный забор, покрашенный зеленой краской (рисунок 2.28). Готовое приложение находится на CD с книгой в каталоге Ch02\Ex14.
Рисунок 2.28. Покрашенный деревянный забор
2.6.2. Веер треугольников (PrimitiveType.TriangleFan) Следующий тип примитивов, PrimitiveType.TriangleFan, используется для рисования вееров треугольников. Первые три вершины (0-я, 1-я и 2-я) задают первый треугольник. Второй треугольник задаѐтся 0-й, 2-й и 3-й вершинами, третий – 0-й, 3-й и 4-й вершинами и т.д. (рисунок 2.29). Данный тип примитивов идеально подходит для рисования эллипсов, окружностей, секторов окружностей и аналогичных фигур. v3 v2 v4
v0 v1
v5
Рисунок 2.29. Веер треугольников, нарисованный с использованием примитивов PrimitiveType.TriangleFan
Чтобы опробовать этот тип примитива на практике, мы модифицируем пример Ex09, заставив его рисовать на экране закрашенный круг вместо окружности (рисунки 1.31). Для этого придѐтся внести три небольших изменения в обработчики событий Load и Paint: 22. Увеличить размер массива вершин на одну вершину. 23. Вставить в начало массива вершину с координатами центра окружности 24. Изменить тип примитива с LineList на TriangleFan Так же мы добавим в программу возможность переключения между каркасным и закрашенным режимами отображения треугольников при помощи клавиши пробел (Space). Эта функциональность, позволяющая просматривать топологию сцены, неоценима при отладке приложения (рисунок 2.31).
Рисунок 2.30. Круг, нарисованный при помощи веера из 64-х треугольников (PrimitiveType.TriangleFan)
Рисунок 2.31. Круг (веер из 18-ти треугольников), визуализированный в каркасном режиме.
Основные фрагменты исходного кода полученного приложения (Ex15) приведены в листинге 2.25. Листинг 2.25. // Количество сегментов в круге const int slices = 64; // Режим закраски круга FillMode fillMode = FillMode.Solid; private void MainForm_Load(object sender, EventArgs e) { ... // Создаѐм графический буфер vertices = new VertexPositionColor[slices + 2]; // Помещаем в начало графического буфера вершину, расположенную в центре экрана vertices[0] = new VertexPositionColor(new Vector3(0.0f, 0.0f, 0.0f), XnaGraphics.Color.White); // Перебираем все вершины окружности for (int i = 0; i <= slices; i++) { // Определяем координаты текущей вершины float angle = (float)i / (float)slices * 2.0f * (float)Math.PI; float x = 0.7f * (float)Math.Sin(angle); float y = 0.7f * (float)Math.Cos(angle); // Вычисляем цвет вершины byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2))); // Помещаем информацию о вершине в массив вершин vertices[i + 1] = new VertexPositionColor(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red, green, 0)); }; ... } private void MainForm_Paint(object sender, PaintEventArgs e) { ...
// Задаем область визуализации размером во весь экран. Для вычисления параметров видового // преобразования используется метод FullScreenViewport нашего вспомогательного класса Helper device.Viewport = Helper.FullScreenViewport(ClientSize); // Закрашиваем поверхность формы device.Clear(XnaGraphics.Color.CornflowerBlue); // Задаем квадратную область закраски максимально возможного размера device.Viewport = Helper.SquareViewport(ClientSize); // Выключаем отсечение треугольников (см. предыдущий раздел) device.RenderState.CullMode = CullMode.None; // Задаѐм режим визуализации треугольников device.RenderState.FillMode = fillMode; device.VertexDeclaration = decl;; // Рисуем круг effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleFan, vertices, 0, vertices.Length - 2); pass.End(); } effect.End(); // Переключаем вспомогательные буферы device.Present(); } // Обработчик события нажатия клавиш private void MainForm_KeyDown(object sender, KeyEventArgs e) { // Если нажата клавиша пробел if (e.KeyCode == Keys.Space) { // Меняем режим отображения if (fillMode == FillMode.Solid) fillMode = FillMode.WireFrame; else fillMode = FillMode.Solid; // Обновляем изображение Invalidate(); } }
2.6.3.Полоса из связанных треугольников (PrimitiveType.TriangleStrip) Последний тип примитивов, PrimitiveType.TriangleStrip, предназначен для рисования полосы из связанных треугольников. При этом первый треугольник проходит через 0-ю, 1-ю 2-ю вершины, второй треугольник – через 3-ю, 2-ю и 1-ю вершины, третий треугольник – через 2-ю, 3-ю и 4-ю вершины, четвѐртый через 5-ю, 4-ю и 3-ю, и т.д (рисунок 2.32). Обратите внимание на порядок перечисления вершин в треугольниках: все треугольники в полосе имеют одинаковую ориентацию относительно часовой стрелки – например, если вершины первого треугольника располагаются по часовой стрелке, то и вершины других треугольников так же будут перечисляться по часовой стрелке. Эта особенность используется при отсечении невидимых треугольников с использованием поля RenderState.CullMode (см. раздел x.x).
v5
v3 v1
v9
v11
v8
v10
V7
v4 v0
v6
v2
Рисунок 2.32. Визуализация полосы связанных треугольников
Данный тип примитивов очень удобно использовать для визуализации ломаных линий шириной больше одного пикселя, то есть в качестве продвинутой версии примитива PrimitiveType.LineStrip. В частности, на рисунке 2.32 в качестве иллюстрации приведена ломаная линия переменной ширины, состоящая из пяти сегментов.
Визуализация графика функции y=cos(x) Чтобы
попрактиковаться
в
рисовании
ломаных
линий
при
помощи
примитива
PrimitiveType.TriangleStrip, мы напишем приложение, визуализирующее график косинуса в
интервале с 0º…720º (0…4·π в радианах) использованием ломаной толщиной 10 пикселей. Хотя на первый взгляд эта задача не намного сложнее практический упражнений №2.1 и №2.2, она всѐ же имеет несколько подвохов. Для начала сформулируем задачу более чѐтко. Нам необходимо построить полосу из связанных треугольников, аппроксимирующую график косинуса, центр которой совпадает с графиком косинуса (рисунок 2.33).
Рисунок 2.33. Полоса из связанных треугольников, аппроксимирующая график косинуса (тонкая линию, проходящая по центру полосы).
Для построения синусоиды мы будем перебирать точки графика косинуса с определѐнным шагом. На рисунке 2.30 эти точки обозначены как p0, p1, p2 и т.д. Отступив симметрично по обе стороны от точки p0 на некоторое расстояние, например, на 0.05 единиц, мы получим две вершины v0 и v1, расстояние межу которыми равно 10 пикселей. Проделав аналогичную операцию над остальными точками, мы получим пары вершин (v2–v3, v4–v5, v6–v7, …, v2·n–v2·n+1), расстояние между которыми равно 0.1 единиц. И, наконец, построив полосу из треугольников, опирающуюся на вершины v0, v1, v2, v3, мы получим ломаную линию толщиной 0.1 пикселей, точно аппроксимирующую график косинуса (рисунок 2.34).
Рисунок 2.34. Построение полосы из треугольников
По ширине график косинуса будет вписан в клиентскую область окна, а по высоте наш график будет немного меньше высоты окна. Таким образом, в действительности наше приложение будет визуализировать не сам график y=cos(x), а несколько другую функцию, полученную путем масштабирования графика y=cos(x) вдоль осей X и Y:
y 0.6 cos(2 x)
(2.9)
где
cos – функция вычисляющая значение косинуса в радианах
x – аргумент функции, лежащий в диапазоне [-1, +1]. Фактически это координата x графика, пробегающая с определѐнным шагом значения от левого до правого краѐв экрана, то есть от -1 до +1. Соответственно аргумент функции косинуса пробегает значения от 0 до 4 (0º...720º).
y – координата y графика функции, лежащая в диапазоне от [-0.7, +0.7].
Перебирая с определѐнным шагом значения координаты x от -1 до +1 и подставляя их в выражение (2.9), мы получим координаты набора точек p0, p1, …, pn (рисунок 2.34). Как говорилось выше, для получения координат вершин полосы треугольников v0, v1, …, v2·n, v2·n+1 необходимо симметрично отупить от точек p0 … pn на 0.05 единиц. Вроде бы всѐ просто и понятно, если не считать одной мелочи: мы пока не ещѐ определились, каким образом должны быть сориентированы отрезки v0–v1, v2–v3, …, v2·n–v2·n+1 относительно точек p0, p1, …, pn. Не мудрствуя лукаво, мы сделаем эти отрезки параллельными оси Y и посмотрим, что из этого выйдет: v0 = p0 - 0.05 v1 = p0 + 0.05 и т.д. Основные фрагменты приложения (Ex16) приведены в листинге 2.26. Листинг 2.26. public partial class MainForm : Form { // Количество сегментов в ломаной линии, аппроксимирующей график косинуса const int QuadStrips = 100; // Число треугольников в ломанной линии const int TriStrips = QuadStrips * 2; ... // Массив вершин VertexPositionColor[] vertices = null; // Режим закраски треугольников FillMode fillMode = FillMode.Solid; ...
(2.10)
private void MainForm_Load(object sender, EventArgs e) { ... // Создаѐм массив вершин для хранения вершин полоски из треугольников vertices = new VertexPositionColor[TriStrips+2]; // Перебираем вершины полоски из треугольников for (int i = 0; i <= QuadStrips; i++) { // Определяем текущее значение координаты x вершины float x = -1.0f + 2.0f * (float) i / (float) QuadStrips; // Вычисляем значение косинуса, соответствующее координате x float angle = 2.0f * (float)Math.PI * x; float cos = (float)Math.Cos(angle); // Вычисляем значение координаты y вершины по формуле 2.9 float y = 0.6f * cos; // Вычисляем красную и зелѐную составляющую цвета по формулам 2.3 (см. практическое // упражнение 2.2) byte green = (byte)(Math.Pow(0.5f + cos * 0.5f, 0.3f) * 255.0f); byte red = (byte)(Math.Pow(0.5f - cos * 0.5f, 0.3f) * 255.0f); // Заносим в массив координаты вершины v[i*2] (см. выражение 2.10) vertices[i * 2] = new VertexPositionColor(new Vector3(x, y - 0.05f, 0.0f), new XnaGraphics.Color(red, green, 0)); // Заносим в массив координаты вершины v[i*2+1] vertices[i * 2 + 1] = new VertexPositionColor(new Vector3(x, y + 0.05f, 0.0f), new XnaGraphics.Color(red, green, 0)); }; ... } private void MainForm_Paint(object sender, PaintEventArgs e) { ... device.Clear(XnaGraphics.Color.DarkSlateGray); // Выключаем отсечение невидимых треугольников device.RenderState.CullMode = CullMode.None; // Задаѐм режим показа треугольников device.RenderState.FillMode = fillMode; device.VertexDeclaration = decl; // Визуализируем полоску из треугольников, аппроксимирующую график косинуса 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(); } }
Скомпилируйте и запустите приложение на выполнение. Как и ожидалось, на экране появится график функции y=cos(x), однако толщина графика будет переменной, причем максимальная толщина графика будет достигаться в окрестностях точек, в которых функция cos(x) принимает значения -1 или 1. (рисунок 2.35). П р им еч а н ие Следующий материал этого раздела содержит довольно много математических выкладок, поэтому если вы не в ладах с математикой, можете смело пропустить оставшуюся часть раздела 2.6.3.
Рисунок 2.35. График функции y=cos(x) переменной толщины
График y=cos(x) постоянной толщины И так, попытка использования отрезков v0–v1, …, v2·n–v2·n+1 параллельных оси Y закончилась неудачей. Ну что ж, отрицательный результат, это тоже результат. Попробуем поэкспериментировать ориентацией отрезков v0–v1, …, v2·n–v2·n+1, например, развернув их под углом 45° (рисунок 2.36): // Вычисляем вектор смещения вершин v[2*i+1] относительно p[n] float nx = (float) (5.0 / Math.Sqrt(2.0)); float ny = nx; // Симметрично смещаем вершины на 5 пикселей в направлении векторов (-1, -1) и (+1, +1) vertices[i * 2] = new VertexPositionColor(new Vector3(x - nx, y - ny, 0.0f), new XnaGraphics.Color(red, green, 0)); vertices[i * 2 + 1] = new VertexPositionColor(new Vector3(x + nx, y + ny, 0.0f), new XnaGraphics.Color(red, green, 0));
Рисунок 2.36. График функции y=cos(x) переменной толщины. Вершины смещаются в направлении векторов (-1, -1) и (+1, +1).
Проведя несколько экспериментов, мы придѐм к выводу, что график косинуса имеет необходимую толщину только в там, где отрезки v2·i–v2·i+1 перпендикулярны графику косинуса. Следовательно, чтобы график функции имел постоянную толщину 0.1 единиц, все отрезки v2·i–v2·i+1 должны быть перпендикулярны графику косинуса. Для нахождения координат вершин отрезка v2·i–v2·i+1 длиной 0.1 единиц, проходящего через точку pi, перпендикулярно графику функции необходимо выполнить следующие действия: 25. Найти вектор,
s перпендикулярный графику функции в точке pi. 26. Найти вектор, параллельный вектору n длиной пять единиц, параллельный вектору s . 27. Для нахождения координат вершин v2·i и v2·i+1 необходимо отложить от точки pi вектора
n и n .
Рассмотрим эти шаги более подробно. Как вы знаете из курса аналитической геометрии, вектор, перпендикулярного графику функции, определяются по формуле:
s (
df ( x, y) df ( x, y ) , ) dx dy
(2.11)
где
s – вектор, перпендикулярный графику функции
f ( x, y) – функция, заданная в неявной форме (f(x, y)=0)
df ( x, y ) df ( x, y ) , – частные производные по x и y dx dy
Чтобы определить значение вектора
s для нашей функции (2.9), перепишем еѐ в неявной форме f(x, y)=0:
y 0.6 cos(2 x) 0 Теперь найдѐм частные производные, являющиеся координатами вектора
(2.12)
s:
s (sx , s y ) sx
d ( y 0.6 cos(2 x)) 1.2 sin(2 x ) dx
(2.13)
sy
d ( y 0.6 cos(2 x)) 1 dy
Нахождение производных в среде MathCAD Если вы немного подзабыли высшую математику, не огорчайтесь. Для нахождения производных можно воспользоваться, к примеру, математическим пакетом MathCAD. Для вычисления значения производной средствами символьной математики пакета MatCAD просто наберите выражение производной, которую выходите вычислить. Затем введите специальный символ → (Ctrl + .) и нажмите Enter, после чего справа от выражения появится вычисленное значение производной (рисунок 2.37). Подробную информацию о среде MathCAD можно найти, к примеру, в [К.22].
Рисунок 2.37. Математический пакет MathCAD.
Зная вектор
n a
s можно легко найти вектор n заданной длины, параллельный вектору s :
s s
(2.14)
где
n – вектор, параллельный вектору s и имеющий длину a a – необходимая длина вектора (в нашем случае 0.05)
s – длина вектора s
После этого определить координаты точек v2·i и v2·i+1 не составит труда:
v2i pi n v2i1 pi n
(2.15)
Имея под рукой формулы 2.13, 2.14 и 2.15 мы можем легко исправить ошибку в примере Ex16 (визуализация графика функций переменной толщины вместо постоянной) путем небольшой модификации фрагмента обработчика события Paint (листинг 2.27). Листинг 2.27. // Полная версия приложения находится на CD диске книги в каталоге Ex02\Ex17. for (int i = 0; i <= QuadStrips; i++) { float x = -1.0f + 2.0f * (float) i / (float) QuadStrips; float angle = 2.0f * (float)Math.PI * x; float cos = (float)Math.Cos(angle); float y = 0.6f * cos; byte green = (byte)(Math.Pow(0.5f + cos * 0.5f, 0.3f) * 255.0f); byte red = (byte)(Math.Pow(0.5f - cos * 0.5f, 0.3f) * 255.0f); // Вычисляем вектор s float sx = (float)(1.2 * Math.PI * Math.Sin(2.0 * Math.PI * x)); float sy = 1.0f; // Вычисляем длину вектора s float length = (float)Math.Sqrt(sx * sx + sy * sy); // Вычисляем вектор nx float nx = sx / length * 0.05f; float ny = sy / length * 0.05f; // Заносим в графический буфер координаты вершин v[i*2] и v[i*2+1] (вычисляются по формуле // 2.15) vertices[i * 2] = new VertexPositionColor(new Vector3(x - nx, y - ny, 0.0f), new XnaGraphics.Color(red, green, 0)); vertices[i * 2 + 1] = new VertexPositionColor(new Vector3(x + nx, y + ny, 0.0f), new XnaGraphics.Color(red, green, 0)); };
Все изменения обработчика события Paint сводятся к добавлению пяти новых строк кода и косметической правке двух строк. Как говорится, дело в количестве строк, а в математических формулах, которые заложены в эти строки. Результат работы приложения приведен на рисунке 2.38.
Рисунок 2.38. График функции y=cos(x) постоянной толщины
Визуализация CD-диска Область применения полос из треугольников не ограничивается визуализацией полосок определѐнной толщины, ведь примитивы PrimitiveType.TriangleStrip активно используются для визуализации самых разнообразных геометрических объектов. В листинге 2.28 приведѐны основные фрагменты исходного кода примера, визуализирующий на экране CD диск (рисунок 2.39).
Рисунок 2.39. CD-диск, нарисованный с использованием полосы из 200 треугольников
Листинг 2.28 public partial class MainForm : Form { // fx-файл const string effectFileName = "Data\\ColorFill.fx"; // Количество сегментов в диске const int slices = 100; // Радиус внутренней границы CD-диска const float innerRadius = 0.2f; // Радиус внешней границы CD-диска const float outerRadius = 0.7f; // Режим закраски треугольников FillMode fillMode = FillMode.Solid; ... private void MainForm_Load(object sender, EventArgs e) { ... // Выделяем память для хранения вершин диска vertices = new VertexPositionColor[slices * 2 + 2]; // Перебираем вершины CD диска for (int i = 0; i <= slices; i++) { // Вычисляем текущий угол α float angle = (float)i / (float)slices * 2.0f * (float)Math.PI; // Вычисляем вспомогательные переменные float sin = (float)Math.Sin(angle); float cos = (float)Math.Cos(angle);
// Вычисляем координаты вершины внутренней границы CD диска float x = innerRadius * sin; float y = innerRadius * cos; // Добавляем вершину внутренней границы диска в массив вершин vertices[i * 2] = new VertexPositionColor(new Vector3(x, y, 0.0f), XnaGraphics.Color.White); // Вычисляем координаты вершины внешней границы CD диска x = outerRadius * sin; y = outerRadius * cos; // Вычисляем цвет вершины byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2))); // Добавляем вершину внешней границы диска в массив вершин vertices[i * 2 + 1] = new VertexPositionColor(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red, green, 0)); }; } private void MainForm_Paint(object sender, PaintEventArgs e) { ... device.Viewport = Helper.FullScreenViewport(ClientSize); device.Clear(ClearFlags.Target, Color.CornflowerBlue, 0.0f, 0); device.Viewport = Helper.SquareViewport(ClientSize); device.RenderState.CullMode = CullMode.None; device.RenderState.FillMode = fillMode; device.VertexDeclaration = decl; // Рисуем CD диск 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(); } ... } // Обработчик событий от клавиатуры, переключающий режимы закраски при нажатии клавиши Space // (Пробел) private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Space) { if (fillMode == FillMode.Solid) fillMode = FillMode.WireFrame; else fillMode = FillMode.Solid;
Invalidate(); } } }
Нетрудно заметить, что пример Ex18 является тривиальной модификацией примера Ex09 из раздела 2.5.2 (круг, переливающийся разнообразными цветами). CD-диск рисуется при помощи замкнутой полосы из треугольников, внутренние вершины которой расположены на окружность радиусом 0.1 единицы, а внешние – на окружности радиусом 0.7 (рисунок 2.40) Координаты точек, лежащих на окружностях вычисляются по формуле 2.2 (раздел 2.5.2).
Рисунок 2.40. CD-диск, состоящий из 18-ти сегментов (36 треугольников), визуализированный в каркасном режиме.
Визуализация квадрата с круглым отверстием В следующем примере (Ex19) мы нарисуем более интересное изображение: квадрат, внутри которого вырезана дырка в виде окружности (рисунок 2.41). Для упрощения задачи в качестве отправной точки будет использоваться исходный код примера Ex18.
Рисунок 2.41. Квадрат с круглым отверстием.
На первый взгляд между квадратом и кругом практически нет ничего общего: граница круга ограничивается окружностью, а граница квадрата – четырьмя отрезками равной длины. Однако вспомним аналитическую геометрию. Граница квадрата, стороны которого расположены под углом 45º к осям X и Y, может быть описана следующими формулами:
x x0 r sin 2 sign(sin )
(2.16)
y y0 r cos 2 sign(cos ) где
x и y – координаты текущей вершины квадрата.
x0 и y0 – координаты центра квадрата.
– угол, пробегающий с некоторым шагом значения от 0° до 360° (0…2·π).
r – половина диагонали квадрата. sign – аналог метода Math.Sign в .NET.
Таким образом, для визуализации повѐрнутого на 45º квадрата, в центре которого вырезано круглое отверстие, необходимо всего лишь немного подправить пример Ex19, подкорректировав формулу для расчета внешней границы CD диска (листинг 2.39). Листинг 2.39. for (int i = 0; i <= slices; i++) { float angle = (float)i / (float)slices * 2.0f * (float)Math.PI; float sin = (float)Math.Sin(angle); float cos = (float)Math.Cos(angle); // Вычисляем вспомогательные переменные, используемые при расчете координат вершин границы // квадрата float sin2 = sin * sin * Math.Sign(sin); float cos2 = cos * cos * Math.Sign(cos); // Вычисляем координаты вершин внутреннего круглого отверстия и заносим в массив вершин float x = innerRadius * sin; float y = innerRadius * cos; vertices[i * 2] = new VertexPositionColor(new Vector3(x, y, 0.0f), XnaGraphics.Color.White); // Вычисляем координаты внешней границы квадрата по формуле 2.16. x = outerRadius * sin2; y = outerRadius * cos2; // Вычисляем цвет вершины byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2))); // Заносим в информацию о вершине в массив вершин vertices[i * 2 + 1] = new VertexPositionColor(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red, green, 0)); };
Рисунок 2.42. Повѐрнутый на 45° квадрат с отверстием, визуализированный в каркасном режиме. Фигура построена с использованием полосы из 40-ка треугольников.
Результат работы примера Ex21 приведѐн на рисунке 2.42. Теперь, нам необходимо развернуть эту фигуру на 45 градусов относительно геометрического центра фигуры. Из курса аналитической геометрии вам должно быть известно, что при повороте изображения на угол φ относительно точки с координатами x0, y0, координаты всех точек изображения трансформируются по следующим формулам:
x x0 ( x x0. ) cos ( y y0 ) sin
(2.17)
y y0 ( x x0. ) sin ( y y0 ) cos где
x0 , y0 – координаты точки, относительно которой выполняется поворот x , y – старые координаты точки
– угол поворота
Подставив вместо x и y выражения 2.16, а так же положив угол формулы, задающие координаты точек квадрата:
x x0
r (cos 2 sign(cos ) sin 2 sign(cos )) 2
y y0
r (cos 2 sign(cos ) sin 2 sign(cos )) 2
равным 45º, мы получим следующие
(2.18)
Модифицируем пример Ex19, изменив вычисление координат вершин внешней границы квадрата согласно выражению 2.18, и запустим программу на выполнение. В результате мы получим любопытную картину: в процессе поворота квадрата на 45º полоса треугольников как бы завинчивается по спирали. (рисунок 2.39) Это обусловлено тем, что мы повернули только внешнюю сторону полосы треугольников, забыв о внутренней стороне, имеющую форму круга. Для поворота круглого отверстия в квадрате на 45º мы скомбинируем выражения 2.17 и 2.2:
x x0
r (cos sin ) 2
y y0
r (cos sin ) 2
(2.19)
Рисунок 2.39. Квадрат, повѐрнутый относительно внутреннего отверстия. Каркасный режим.
После доработки приложения согласно выражению 2.19 изображение наконец-то примет нормальный вид (рисунки 2.37 и 2.40). Исправленный код с учѐтом выражений 2.18 и 2.19 приведен в листинге 2.40 (Ex20):
Рисунок 2.44. Квадрат с круглым отверстием, визуализированный в каркасном режиме.
Листинг 2.40. // Несколько увеличиваем размер квадрата и отверстия const float innerRadius = 0.285f; const float outerRadius = 1.0f; // Константа, используемая при расчете вершин квадрата readonly float sqrt2 = (float)Math.Sqrt(2.0); ... for (int i = 0; i <= slices; i++) { float angle = (float)i / (float)slices * 2.0f * (float)Math.PI; float sin = (float)Math.Sin(angle);
float cos = (float)Math.Cos(angle); float sin2 = sin * sin * Math.Sign(sin); float cos2 = cos * cos * Math.Sign(cos); float x = innerRadius /sqrt2 * (cos + sin); float y = innerRadius / sqrt2 * (cos - sin); vertices[i * 2] = new VertexPositionColor(new Vector3(x, y, 0.0f), XnaGraphics.Color.White); x = outerRadius * outerRadius/sqrt2 * (cos2 + sin2); y = outerRadius * outerRadius / sqrt2 * (cos2 - sin2); byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2))); vertices[i * 2 + 1] = new VertexPositionColor(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red, green, 0)); };
Практическое упражнение №2.4 Выражения 2.18 и 2.19 являются частными случаями так называемой суперокружности, координаты точек которой задаются формулами42:
r n n ( cos sign(cos ) sin sign(cos )) 2 r n n y y0 ( cos sign(cos ) sin sign(cos )) 2
x x0
(2.20)
где
x , y – координаты текущей точки.
x0 , y0 – координаты центра суперокружности.
r – радиус суперокружности. n – степень суперокружности
– угол, пробегающий с некоторым шагом все значения от 0º до 360º.
При n=1, суперокружность приобретает форму обычной окружности (формула 2.19), а при n=2 – квадрата (формула 2.18). При других n суперокружность приобретает различные интересные формы. Более подробную информацию о суперокружностях можно найти в [К.23]. Напишите приложение, визуализирующую фигуру, имеющую форму суперокружности, степень n которой равна 2.5. При этом в центре этой фигуры должно быть вырезано отверстие в форме суперокружности степени 1.3 (рисунок 2.45).
42
Если быть более точным, формула 2.20 задаѐт суперокружность, повѐрнутую на 45 градусов
Рисунок 2.45. Фигура, границы которой задаются двумя суперокружностями. Внешняя суперокружность имеет степень 2.5, а внутренняя 1.3.
Готовое приложение можно найти на CD диске в каталоге \Examples\Ch02\Ex21.
Практическое упражнение №2.5 Создайте приложение, визуализирующее фигуру Лиссажу, координаты точек которой задаются следующей формулой:
x sin(2 )
(2.21)
y cos(3 ) где
x , y – координаты текущей точки
– угол, пробегающий с определѐнным шагом значения от 0º до 360º (0…2·π)
График функции должен быть визуализирован с использованием линии толщиной 10 пикселей (рисунок 2.46).
Рисунок 2.46. Фигура Листажу
На всякий случай, ниже приведены математические выкладки, которые могут облегчить написание программы. Для построения фигуры Лиссажу, вписанной в форму с небольшими отступами по краям, выражение 2.21 необходимо преобразовать к следующему виду:
x 0.85 sin(2 )
(2.22)
y 0.85 cos(3 ) Для нахождения вектора касательной в точке x, y достаточно найти производные по
:
k (k x , k y ) kx
d (0.85 sin(2 )) 1.7 cos(2 ) d
ky
d (0.85 cos(3 )) 2.55 sin(3 ) d
(2.23)
где
k – вектор касательной
k x , k y – компоненты этого вектора Зная вектор касательной, можно легко найти вектор, перпендикулярный графику функции:
s ( k y , k x ) sx k y 2.55 sin(3 )
(2.24)
s y k x 1.7 cos(2 ) Имея перпендикуляр к графику функции ( s ), вы сможете легко найти координаты вершин полосы из треугольников (по аналогии примером построения графика синуса из начала раздела). Готовое приложение можно найти на CD с книгой в каталоге \Examples\Ch02\Ex22.
Заключение В отличие от универсальных графических библиотек вроде GDI, XNA Framework поддерживает визуализацию весьма ограниченного набора примитивов: точки, отрезки и треугольники. Любой визуализируемый объект, какой сложной формы он не был, всегда аппроксимируется набором этих примитивов. Хотя подобный подход заметно усложняет жизнь разработчика, он позволяет достичь беспрецедентного уровня производительности (порядка нескольких сотен миллионов визуализированных примитивов в секунду). Примитивы в процессе визуализации обрабатываются специализированными вершинными и пиксельными процессорами видеокарты. Вершинные процессоры предназначены для преобразования вершин, а пиксельные – для закраски примитивов. В DirectX и XNA Framework вершинные и пиксельные процессоры программируются с использованием семейства ассемблеро-подобных языков Vertex Shader и Pixel Shader. Каждый из этих языков оптимизирован под определѐнный тип GPU. Например, язык Pixel Shader 1.3 был специально разработан для программирования пиксельных процессоров NV25 (GeForce4). При загрузке шейдера программа, написанная на ассемблеро-подобном языке автоматически компилируется в машинный код текущего GPU; таким образом, языки Vertex Shader и Pixel Shader очень похожи на промежуточный язык IL в .NET. По мере роста функциональности вершинных и пиксельных процессоров появилась потребность в языке программирования высокого уровня. Таким языком стал HLSL (High Level Shader Language) – C-подобный язык программирования, предназначенный для программирования вершинных и пиксельных процессоров. Программа, написанная на HLSL, компилируется в промежуточный ассемблеро-подобный язык с использованием профиля, указывающего под какой ускоритель необходимо оптимизировать генерируемый код. Так, профиль ps_2_a указывает, что шейдер будет транслироваться в язык Pixel Shader 2.x и оптимизирован для GPU семейства NV3x (GeForce FX). Как правило, чем выше номер профиля, тем большая функциональность доступна программисту, однако платой за эту гибкость является рост требований приложения к функциональности GPU (см. приложение 2).
Глава 3. Усложненные технологии визуализации. Любому разработчику периодически приходится решать множество типовых задач визуализации: Как ограничить область вывода изображения определенным участком формы? Как осуществить визуализацию в полноэкранном режиме с заданным разрешением экрана? Как реализовать анимацию объектов? Как визуализировать полупрозрачные примитивы? Ответы на все эти вопросы будут даны в этой главе. Начнем с решения первой проблемы.
3.1. Вывод на элементы управления .NET средствами XNA Framework. До сих пор все наши приложения осуществляли монопольный вывод графической информации на поверхность формы. Такой подход не позволяет размещать на форме элементы управления .NET, что значительно ограничивает свободу разработчика. Конечно, можно попробовать использовать различные вспомогательные диалоговые окна и плавающие панели инструментов, но это не очень красивое решение проблемы, хотя и вполне работоспособное 43. Гораздо интереснее было бы научиться выводить информацию не на саму форму, а на элементы управления, размещѐнные на ней (к примеру, на элемент управления Panel). Это позволило бы нам легко ограничить область вывода XNA Framework определѐнной областью формы, а освободившееся пространство использовать для размещения различных элементов управления Windows Forms. Вывод на поверхность элемента управления практически не отличается от вывода на поверхность формы, ведь в Windows Forms форма является частным случаем элементом управления. Более, форму можно поместить на другую форму или компонент в качестве элемента управления 44 [К.24]. Это возможно благодаря тому, что любая форма или элемент управления напрямую или косвенно наследуется от общего предка – класса Control. Как следствие, любой элемент управления Windows Forms обладает свойствами Handle, Width, Height, методами SetWidth, Show, Hide, обработчиками событий Paint, Click, Resize и так далее45. П р им еч а н ие Класс Control является полноценным элементом управления, по функциональности отдалѐнно напоминающий элемент Panel с немного ограниченными возможностями.
Но вот незадача, некоторые члены класса Control объявлены как protected. Это не создаѐт никаких проблем при создании новой формы путѐм наследования от класса Form, так как в этом случае вы автоматически получаете полный доступ к защищѐнным (protected) членам класса. Однако при использовании готовых компонентов, помещаемых на форму средствами визуального редактора форм Visual Studio, всѐ намного серьезнее. Например, вы не сможете получить доступ к такому важному методу как SetStyle, чтобы установить стиль ControlStyles.Opaque. А ведь без этого невозможно запретить самовольную перерисовку элемента управления, приводящую к мерцанию изображения в XNA-приложениях (раздел 1.2.3). Наиболее красивое решение этой проблемы – создание на базе класса Control собственного элемента управления, изменяющего статус метода SetStyle с protected на public. Для этого запустите Visual Studio и создайте новый проект библиотеки классов XnaPanel (File | New Project… | Class Library, в текстовом поле Name введите XnaPanel и нажмите OK). Установите флажок Create directory for solution, так как в решении будет еще один проект, предназначенный для тестирования созданного компонента. Подключите к проекту сборку System.Windows.Forms и введите код из листинга 3.1. Листинг 3.1. using using using using 43
System; System.Collections.Generic; System.Text; System.Windows.Forms;
Подобный подход применяется, к примеру, в редакторе сценариев (Activity Editor) игры Microsoft Train Simulator. 44 Этот приѐм используется в редакторе форм Visual Studio 45 Полный перечень свойств и методов класса Control можно найти в MSDN.
namespace GSP.XNA { // Объявляем класс (элемент управления) XnaPanel, наследуемый непосредственно от Control public class XnaPanel : Control { // Конструктор элемента управления public XnaPanel() { // Изменяем цвет элемента управления, чтобы он выделялся среди других элементов формы BackColor = Color.CornflowerBlue; // Размер элемента управления не должен быть меньше 1x1 пикселей MinimumSize = new Size(1, 1); } // Переопределяем метод SetStyle метода Control как public public new void SetStyle(ControlStyles flag, bool value) { // Вызываем оригинальный метод класса Control base.SetStyle(flag, value); } } }
Ва жно Для предотвращения неконтролируемого уменьшения размера компонента xnaPanel до размеров меньше одного пикселя, его свойству MinimumSize необходимо присвоить значение Size(1, 1). Если этого не сделать, площадь области визуализации теоретически может достигнуть нуля, что приведет к генерации исключения при сбросе устройства (см. раздел 1.2.3).
После компиляции проекта Visual Studio самостоятельно создаст в окне Toolbox группу XnaPanel Components и добавит в неѐ полученный элемент управления (рисунок 3.1).
Рисунок 3.1. Панель Toolbox с компонентом XnaPanel
Для тестирования нашего компонента мы создадим простое приложение, визуализирующее треугольник. В правой части формы будет расположена панель с элементами управления, позволяющими изменять цвета вершин треугольника (рисунок 3.2). Чтобы добавить к решению ещѐ один проект щѐлкните правой кнопкой мыши на названии решения в окне Solution Explorer и выберите в контекстом меню пункт Add | New Project… (рисунок 3.3). В появившемся диалоговом окне выберите элемент Windows Application, введите в поле Name название приложения (например, Test) и нажмите кнопку Ok. Как всегда, подключите к проекту необходимые сборки XNA Framework и добавьте необходимые директивы using.
Рисунок 3.2. Внешний вид тестового приложения, использующего компонент XnaPanel
Рисунок 3.3. Добавление к решению (Solution) нового проекта
Поместите на форму компонент SplitContainer, расширьте его на всю форму путем присвоения параметру Dock значения Fill и зафиксируйте размер правой панели, присвоив свойству FixedPanel значение Panel2. В правой панели компонента SplitContainer разместите группу (Group) «Параметры» и тоже расширьте еѐ на всю панель при помощи свойства Dock. В группе создайте три метки (Label) расположенные друг под другом: Цвет вершины №1, Цвет вершины №2, Цвет вершины №3. Поместите напротив этих меток три панели (Panel) и присвойте им имена vertex1Panel, vertex2Panel и vertex3Panel. Чтобы создать чѐрные рамки вокруг панелей, установите свойство BorderStyle в значение FixedSingle. Поместите в левой панели компонента SplitContainer наш компонент XnaPanel, назовите его xnaPanel и расширьте его на всю свободную область формы, присвоив свойству Dock значение Fill. В заключение, поместите на форму невизуальный компонент ColorDialog. Следующий этап – “оживление” формы путѐм создания обработчиков событий. Для начала мы добавим в форму несколько вспомогательных полей и обработчики событий Load и FormClosed (листинг 3.2). Листинг 3.2. // Различные вспомогательные поля GraphicsDevice device = null; PresentationParameters presentParams; Effect effect = null; VertexDeclaration decl = null; VertexPositionColor[] vertices = null; bool closing = false; private void MainForm_Load(object sender, EventArgs e) { // Устанавливаем стили компонента xnaPanel xnaPanel.SetStyle(ControlStyles.Opaque | ControlStyles.ResizeRedraw, true);
// Задаѐм структуру presentParams presentParams = new PresentationParameters(); presentParams.IsFullScreen = false; presentParams.BackBufferCount = 1; presentParams.BackBufferWidth = xnaPanel.ClientSize.Width; presentParams.BackBufferHeight = xnaPanel.ClientSize.Height; presentParams.SwapEffect = SwapEffect.Discard; // Проверяем аппаратную поддержку вершинных процессоров GraphicsDeviceCapabilities caps = GraphicsAdapter.DefaultAdapter.GetCapabilities(DeviceType.Hardware); CreateOptions options = CreateOptions.SingleThreaded; if (caps.DeviceCapabilities.SupportsHardwareTransformAndLight) options |= CreateOptions.HardwareVertexProcessing; else options |= CreateOptions.SoftwareVertexProcessing; // Создаѐм новое устройство. В качестве третьего параметра (IntPtr renderWindowHandle) // передаѐтся дескриптор компонента xnaPanel device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, xnaPanel.Handle, options, presentParams); // Выделяем память для хранения трѐх вершин vertices = new VertexPositionColor[3]; // Загрузка и компиляция эффекта ... } private void MainForm_FormClosed(object sender, FormClosedEventArgs e) { // Освобождаем устройство if (device != null) { device.Dispose(); device = null; } }
Как видно, обработчик события Load мало чем отличается от аналогичных обработчиков предыдущих примеров. Все изменения фактически сводятся к замене неявной ссылки this на xnaPanel. Обработчики событий Paint и Resize элемента управления xnaPanel реализуются аналогичным образом (листинг 3.3). Листинг 3.3. private void xnaPanel_Paint(object sender, PaintEventArgs e) { // Если при инициализации приложения возникли проблемы, выходим из обработчика события if (closing) return; try { // Если устройство не может быть восстановлено, прерываем работу функции if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.Lost) throw new DeviceLostException(); // Если устройство может быть восстановлено, восстанавливаем его if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.NotReset) device.Reset(presentParams); // Задаем область визуализации размером во всю клиентскую область компонента xnaPanel
device.Viewport = Helper.FullScreenViewport(xnaPanel.ClientSize); // Очищаем экран device.Clear(XnaGraphics.Color.CornflowerBlue); // Задаем квадратную область визуализации device.Viewport = Helper.SquareViewport(xnaPanel.ClientSize); // Выключаем отсечение невидимых треугольников device.RenderState.CullMode = CullMode.None; // Указываем используемую декларацию формата вершины device.VertexDeclaration = decl; // Заносим в графический буфер координаты треугольника. Обратите внимание на несовместимость // типов Microsoft.Xna.Framework.Graphics.Color и System.Drawing.Color, что вынуждает нас // использовать “китайскую грамоту” при конвертировании цвета из одного типа в другой. vertices[0] = new VertexPositionColor(new Vector3(0.0f, 0.4f, 0.0f), new XnaGraphics.Color(vertex1Panel.BackColor.R, vertex1Panel.BackColor.G, vertex1Panel.BackColor.B)); vertices[1] = new VertexPositionColor(new Vector3(0.4f, -0.4f, 0.0f), new XnaGraphics.Color(vertex2Panel.BackColor.R, vertex2Panel.BackColor.G, vertex2Panel.BackColor.B)); vertices[2] = new VertexPositionColor(new Vector3(-0.4f, -0.4f, 0.0f), new XnaGraphics.Color(vertex3Panel.BackColor.R, vertex3Panel.BackColor.G, vertex3Panel.BackColor.B)); // Рисуем треугольник effect.Begin(); foreach(EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleList, vertices, 0, vertices.Length / 3); pass.End(); } effect.End(); // Переключаем экранные буферы device.Present(); } // Обрабатываем ситуацию внезапной потери устройства catch (DeviceNotResetException) { Invalidate(); } catch (DeviceLostException) { } } private void xnaPanel_Resize(object sender, EventArgs e) { // Если форма не минимизирована if (WindowState != FormWindowState.Minimized) { // Обновляем размер формы presentParams.BackBufferWidth = xnaPanel.ClientSize.Width;
presentParams.BackBufferHeight = xnaPanel.ClientSize.Height; // Сбрасываем устройство device.Reset(presentParams); } }
В заключении мы создадим обработчик щелчка мышью на панели выбора цвета вершины: при щелчке левой кнопкой мыши будет открываться стандартное диалоговое окно Windows выбора цвета, после чего панель окрасится цветом, выбранным пользователем, а изображение в компоненте xnaPanel будет перерисовано с учетом нового цвета вершины. Для этого выделите панель vertex1Panel и создайте следующий обработчик события Click: private void vertex1Panel_Click(object sender, EventArgs e) { // Преобразовываем параметр sender к Panel Panel panel = (Panel)sender; // Выбираем в диалоговом окне текущий цвет вершины colorDialog1.Color = panel.BackColor; // Показываем стандартный диалог выбора цвета if (colorDialog1.ShowDialog() == DialogResult.OK) { // Если пользователь выбрал цвет, изменяем цвет панели на выбраный panel.BackColor = colorDialog1.Color; // Обновляем содержимое нашего компонента xnaPanel panelDX.Invalidate(); } }
Как видно, обработчик получает информацию о нажатой панели из параметра sender, что позволяет без изменений использовать этот обработчик для панелей vertex2Panel и vertex3Panel: просто выделите эти панели и назначьте в качестве обработчика события Click метод vertex1Panel_Click. Остается лишь откомпилировать программу и посмотреть результат. Готовое приложение можно найти на CD диске в каталоге Examples\Ch03\Ex01. П р им еч а н ие В Visual Studio выбор активного проекта46 осуществляется при помощи контекстного меню: щѐлкните на названии проекта в окне Solution Explorer и выберите в появившемся контекстном меню пункт Set as StartUp Project (рисунок 3.4).
46
Проект, который запускается при нажатии клавиши F5
Рисунок 3.4. Выбор активного проекта с использованием контекстного меню
Практическое упражнение №3.1. Напишите приложение, отображающее круг. В правой части окна должны быть расположены элементы управления, позволяющие задавать различные параметры изображения: радиус круга, количество секторов в круге, режим визуализации круга (поточечный, каркасный или с закраской) и цвет круга (рисунок 3.5).
Рисунок 3.5. Иллюстрация к практическому упражнению №3.1.
Для ограничения области визуализации XNA Framework воспользуйтесь компонентом XnaPanel из примера Ch03\Ex02. Существует два способа добавления компонента XnaPanel в окно Toolbox: 28. Подключить к решению проект XnaPanel из примера Ch03\Ex01. Для этого во вкладке Solution Explorer щелкнете правой кнопкой мыши на узле с названием решения (Solution) и выберете в контекстном меню пункт Add | Existing Project… . Укажите в открывшемся окне файл проекта компонента PanelDX (Examples\Ch03\Ex01 - XnaPanel\XnaPanel\XnaPanel.csproj) и нажмите Ok. После компиляции подключенного проекта (Ctrl + Shift + B) компонент Xna Panel автоматически появится в окне Toolbox. 29. Добавить в окно ToolBox компонент из сборки примера Ex01. Для этого щѐлкните правой кнопкой мыши на поверхности окна ToolBox и выберите в контекстном меню пункт Choose Items… . Откроется диалоговое окно Choose Toolbox Items… . Щелкните на кнопку Browse… и укажите сборку с компонентом PanelDX (Examples\Ch03\Ex01 - XnaPanel\XnaPanel\bin\Release\XnaPanel.dll). Наконец, нажмите кнопку Ok, после чего на панели Toolbox появится компонент XnaPanel. Какой из этих двух вариантов выбрать – решать вам, но лично мне больше симпатизирует первый подход.
3.2. Полноэкранный режим. Как известно, подавляющее большинство игровых приложений работают в полноэкранном режиме. На это есть несколько веских причин: 30. В полноэкранном режиме пользователь полностью погружается в виртуальный мир, не отвлекаясь на различные элементы интерфейса Windows вроде панели задач (Taskbar). 31. Контроль всего пространства экрана позволяет переключать экранный и задний (back) буферы без необходимости копирования информации между буферами: при показе итогового изображения XNA Framework просто делает задний буфер экранным, а экранный задним. 32. При запуске приложения на компьютере с недостаточно мощной видеокартой пользователь может повысить производительность путем уменьшения разрешения экрана и глубины цвета. К недостаткам полноэкранного режима можно отнести несколько усложнение кода приложения и невозможность использования элементов управления Windows Forms, что затрудняет применение полноэкранного режима в различных утилитах. Переход полноэкранный режим осуществляется путем создания графического устройства (GraphicsDevice) с соответствующим образом настроенной структурой PresentationParameters. Легко догадаться, что свойству IsFullScreen необходимо присвоить значение true: presentParams = new PresentationParameters(); presentParams.IsFullScreen = true;
Кроме того, приложение должно задать параметры используемого видеорежима, а именно:
33. Разрешение экрана. 34. Формат пикселей экрана (количество бит на пиксель и т.п.). 35. Частоту вертикальной развертки. Разрешение47 экрана и вспомогательных (back) буферов задается свойствами BackBufferWidth и BackBufferHeight структуры PresentationParameters. Например: // Используем видеорежим с разрешением 640 × 480 presentParams.BackBufferWidth = 640; presentParams.BackBufferHeight = 480;
Любой видеорежим характеризуется определенным форматом пикселей, используемым для хранения информации о цвете пикселей. Формат пикселей определяет, сколько бит отводится для хранения каждого цвета и как распределены биты между различными цветовыми каналами. Формат пикселей заднего буфера задается свойством BackBufferFormat структуры PresentationParameters: public SurfaceFormat BackBufferFormat { get; set; }
где
SurfaceFormat – перечислимый тип, используемый для задания формата пикселей (таблица 3.1).
В качестве формата экранного буфера автоматически используется формат, наиболее близкий к формату заднего буфера. Например, если вы укажете для заднего буфера формат SurfaceFormat.Color, в качестве формата экранного буфера будет выбран формат SurfaceFormat.Bgr32. Обычно это не существенно, ведь альфа-канал всѐ равно не оказывает никакого влияния на отображаемом на экране изображении; тем не менее, в некоторых немногочисленных ситуациях этот нюанс всѐ же приходится учитывать (в чем мы убедимся в разделе 3.2.2). Таблица 3.1. Характеристика форматов пикселей, соответствующих значениям перечислимого типа SurfaceFormat Значение
Может использоваться в качестве формата экранного буфера
Unknown Rgba1010102
X
Color
Размер в битах Весь пиксель
Красный канал
Зеленый канал
Синий канал
Альфа канал
?
?
?
?
?
32
10
10
10
2
32
8
8
8
8
Bgr32
X
32
8
8
8
-
Bgr565
X
16
5
6
5
-
16
5
5
5
1
Bgra5551
По умолчанию свойство SurfaceFormat равно SurfaceFormat.Unknown. В оконном режиме это значение указывает на необходимость использования в качестве формата заднего буфера такой же формат пикселей, как у рабочего стола. Например, если вы используете в Windows 32-х битный видеорежим, задний буфер будет использоваться формат SurfaceFormat.Bgr32. Эта особенность позволила нам не утруждать себя выбором формата заднего буфера для оконных приложений первой и второй глав книги. Однако применение формата SurfaceFormat.Unknown для полноэкранного графического устройства приводит к генерации исключения System.InvalidOperationException. Так что при создании устройства, использующего полноэкранный режим, приложение обязано явно указывать формат пикселей заднего буфера. В нашем первом приложении мы, не мудрствуя лукаво, будем использовать формат SurfaceFormat.Bgr32, поддерживаемый всеми видеокартами, совместимыми с XNA Framework: presentParams.BackBufferFormat = SurfaceFormat.Bgr32;
П р им еч а н ие Должно быть, вы заметили, что пиксели формата SurfaceFormat.Bgr32 имея размер 32 бита, содержат всего 24 бита полезной информации (для красного, синего и зеленого каналов отведено по 8 бит). Такая избыточность обусловлена тем, что современные процессоры умеют работать только с типами данных разрядностью 8, 16, 32 и 64 бит. Соответственно, 24-х битные типы данных пришлось бы эмулировать посредством 8, 16 и 32-х разрядных типов, что неминуемо увеличило количество операций, требуемых для считывания и записи значения пикселя и, соответственно, ощутимо снизило производительность. Например, для записи 24-х битного значения необходимо 47
Количество пикселей по высоте и ширине.
загрузить регистр 32 бита, модифицировать 24 бита, и записать полученное 32-х битное значение обратно в память.
И, наконец, последней характеристикой любого графического режима является частота обновления экрана. Для современных электронно-лучевых трубок нормой является частота обновления порядка 85 герц и выше, так как при более низких частотах становится заметным мерцание монитора. Мониторы LCD менее критичны к низкой частоте обновления экрана, однако редкий современный LCD-монитор поддерживает частоту смены кадров более 60 Гц. Частота обновления экрана задается свойством FullScreenRefreshRateInHz структуры PresentationParameters: public int FullScreenRefreshRateInHz { get; set; }
В оконном режиме этому свойству присевается нулевое значение, ведь окно всѐ равно может обновляться с частотой, отличной от частоты обновления, используемой рабочим столом. В полноэкранном режиме нулевое значение параметра FullScreenRefreshRateInHz, означает, что приложение отдает выбор частоты обновления экрана на откуп Windows и драйверам видеокарты/монитора. Так как при указании частоты обновления неподдерживаемой текущей видеоподсистемой может быть сгенерировано исключение System.InvalidOperationException, мы пока не будем пытаться самостоятельно выбирать частоту обновления экрана. Таким образом, код создания графического устройства должен выглядеть аналогично листингу 3.4. Листинг 3.4. presentParams = new PresentationParameters(); presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; // Используем полноэкранный режим 640×480, 32 бита на пиксель, частота обновления выбирается // автоматически presentParams.IsFullScreen = true; presentParams.BackBufferWidth = 640; presentParams.BackBufferHeight = 480; presentParams.BackBufferFormat = SurfaceFormat.Bgr32; presentParams.FullScreenRefreshRateInHz = 0;
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);
Так же необходимо подправить код обработчика событий Resize. Дело в том, что даже в полноэкранном режиме форма продолжает жить своей жизнью и в ряде случаях (при переключении в другое приложение путем нажатия клавиш ALT + TAB) Windows может самостоятельно изменять размер формы, что, разумеется, приводит к генерации события Resize. Но так как в полноэкранном режиме размер формы некоим образом не связан с разрешением экрана, приложение не должно изменять размер заднего ( back) буфера: Листинг 3.5. private void MainForm_Resize(object sender, EventArgs e) { // Если приложение находится в полноэкранном режиме, оно не должно отслеживать изменение // размера формы if ((!presentParams.IsFullScreen) && (WindowState != FormWindowState.Minimized)) { presentParams.BackBufferWidth = ClientSize.Width;
presentParams.BackBufferHeight = ClientSize.Height; device.Reset(presentParams); } }
Визуализация сцены осуществляется аналогично оконному режиму: Листинг 3.6. private void MainForm_Paint(object sender, PaintEventArgs e) { if (closing) return; try { if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.Lost) throw new DeviceLostException(); if (device.GraphicsDeviceStatus == GraphicsDeviceStatus.NotReset) device.Reset(presentParams); // Задаем область визуализации размеров во весь экран device.Viewport = Helper.FullScreenViewport presentParams); // Очищаем весь экран device.Clear(XnaGraphics.Color.CornflowerBlue); // Используем квадратную область визуализации (во избежание геометрических искажений // изображения) device.Viewport = Helper.SquareViewport(presentParams); // Рисуем CD-диск (см. раздел 2.6.3) ... } catch (DeviceNotResetException) { Invalidate(); } catch (DeviceLostException) { } }
Стоит отметить, что в связи с переходом к полноэкранному режиму при вычислении квадратной области в центре экрана используются не размеры клиентской формы, а информация о размере заднего буфера из структуры PresentationParameters presentParams. Собственно вычисление размеров области визуализации осуществляется двумя дополнительными перегруженными методами: Листинг 3.7. // Вычисление квадратной области визуализации public static Viewport SquareViewport(PresentationParameters presentParams) { return SquareViewport(new System.Drawing.Size(presentParams.BackBufferWidth, presentParams.BackBufferHeight)); } // Вычисление области визуализации размеров во весь экран public static Viewport FullScreenViewport(PresentationParameters presentParams) { return FullScreenViewport(new System.Drawing.Size(presentParams.BackBufferWidth, presentParams.BackBufferHeight));
}
Готовое приложение, визуализирующее изображение CD-диска в полноэкранном режиме (рисунок 3.6), находится в каталоге Examples\Ch03\Ex03.
Рисунок 3.6. CD-диск, визуализированный полноэкранном режиме (640×480×32bpp)
3.2.1. Выбор оптимального видеорежима. В примере Ch03\Ex03 (листинг 3.4) мы неявно предполагаем, что абсолютно все современные и будущие видеоподсистемы будут поддерживать видеорежим 640×480×32bpp48. Если это вдруг окажется не так, приложение аварийно завершит работу с исключением System.InvalidOperationException. Поэтому было бы логичным на всякий случай предусмотреть поведение приложение при отсутствии поддержки требуемого видеорежима. Например, при неудачной попытке создания полноэкранного графического устройства приложение могло бы попытаться создать графическое устройство, осуществляющее визуализацию в окне: presentParams = new PresentationParameters(); presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; presentParams.IsFullScreen = true; // Используем полноэкранный режим 640×480, 32 бита на пиксель, 60 герц presentParams.BackBufferWidth = 640; presentParams.BackBufferHeight = 480; presentParams.BackBufferFormat = SurfaceFormat.Bgr32; presentParams.FullScreenRefreshRateInHz = 60; try { device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, options, presentParams); } catch(InvalidOperationException) { // Если попытка использования полноэкранного режима потерпела фиаско, используем // визуализацию в окне presentParams.IsFullScreen = false; presentParams.BackBufferWidth = 0; presentParams.BackBufferHeight = 0; presentParams.BackBufferFormat = SurfaceFormat.Unknown; presentParams.FullScreenRefreshRateInHz = 0;
48
BPP – Bit Per Pixel (Бит на пиксель)
// После выполнения этой команды интерфейс формы будет заблокирован: форму нельзя // будет перемещать по экрану, изменять еѐ размер, минимизировать и т.п. device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, options, presentParams); }
Хотя этот не содержит ничего противоестественного, на практике он не будет нормально функционировать: из-за ошибки в XNA Framework после неудачной попытки перехода в полноэкранный режим приложение уже не может использовать оконный режим. В следующих версиях XNA Framework эта проблема по видимости будет исправлена, ну а пока нам придется, как и всем настоящим героям, идти в обход 49. И так, при создании полноэкранного графического устройства необходимо подобрать видеорежим, гарантированно поддерживаемый данной видеокартой и монитором. А почему бы нам вместо гадания на кофейной гуще не использовать текущий видеорежим рабочего стола, по определению поддерживаемый видеоподсистемой компьютера? Ведь, как известно, в качестве видеорежима рабочего стола обычно используется видеорежим, наиболее оптимальный для текущего монитора компьютера. Это особенно актуально для LCD-мониторов, оптимизированных для работы каком-то одном разрешении экрана (как правило, 1024×768 или 1280×1024). Информация о текущем видеорежиме доступна посредством свойства CurrentDisplayMode класса GraphicsAdapter: public DisplayMode CurrentDisplayMode { get; }
Вся информация о видеорежиме хранится в структуре DisplayMode: public struct DisplayMode { // Количество пикселей по горизонтали public int Width { get; } // Количество пикселей по вертикали public int Height { get; } // Формат пикселей public SurfaceFormat Format { get; } // Частота обновления экрана public int RefreshRate { get; } ... }
Соответственно, код создания графического устройства примет следующий вид: Листинг 3.8. // Получаем описание текущего видеорежима DisplayMode displayMode = GraphicsAdapter.DefaultAdapter.CurrentDisplayMode; presentParams = new PresentationParameters(); presentParams.IsFullScreen = true; presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; // Использует такие же параметры визуализации, как у текущего видеорежима presentParams.BackBufferWidth = displayMode.Width; presentParams.BackBufferHeight = displayMode.Height; presentParams.BackBufferFormat = displayMode.Format; presentParams.FullScreenRefreshRateInHz = displayMode.RefreshRate; GraphicsDeviceCapabilities caps = GraphicsAdapter.DefaultAdapter.GetCapabilities(DeviceType.Hardware); CreateOptions options = CreateOptions.SingleThreaded; if (caps.DeviceCapabilities.SupportsHardwareTransformAndLight) options |= CreateOptions.HardwareVertexProcessing; else options |= CreateOptions.SoftwareVertexProcessing; 49
По-видимости, виновником данной проблемы является не сам XNA Framework, а текущая версия DirectX, используемая XNA Framework.
device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, options, presentParams);
3.2.2. Получение списка доступных видеорежимов. Хотя использование видеорежима рабочего стола даѐт вполне нормальные результаты, в ряде случаев подобный подход оказывается неприемлемым. В основном это касается дешевых видеокарт, демонстрирующих довольно низкую производительность в современных трехмерных приложениях, в результате чего приемлемая частота смены кадров достигается лишь в самых низких разрешениях вроде 640×480 или 800×600. Поэтому было логичным предоставить пользователю возможность самостоятельно выбирать параметры видеорежима в зависимости от своих потребностей. Наше следующее приложение будет отображать на экране перечень всех видеорежимов, поддерживаемых видеокартой и затем переключаться в видеорежим, выбранный пользователем, и визуализировать изображение диска (рисунок 3.7). Эту функциональность достаточно легко реализовать, так как разработчики XNA Framework снабдили класс GraphicsAdapter коллекцией SupportedDisplayModes, содержащей набор структур DisplayMode с информацией обо всех видеорежимах, поддерживаемых указанной связкой видеокарта – монитор.
Рисунок 3.7. Диалоговое окно выбора видеорежима.
Итак, создайте новое приложение Windows Forms и добавьте в него новую форму. Присвойте свойствам формы значения согласно таблице 3.2. Поместите на форму компонент ListBox и назовите его displayModeListBox. В заключение, поместите на форму кнопку Ok и присвойте еѐ свойству DialogResult значение OK. Таблица 3.2. Свойства формы выбора видеорежима Свойство
Значение
Name
DisplayModeForm
Text
Выберите видеорежим
FormBorderStyle
FixedDialog
MaximizeBox
False
MinimizeBox
False
Теперь реализуем логику работы формы, а именно: конструктор формы и свойство SelectedDisplayMode, возвращающее информацию о выбранном видеорежиме (листинг 3.9). Листинг 3.9.
public partial class DisplayModeForm : Form { // Конструктор формы. В качестве параметра принимает объект графического адаптера, для // которого необходимо выбрать графический режим. public DisplayModeForm(GraphicsAdapter adapter) { InitializeComponent(); // Перебираем все графические режимы, поддерживаемые графическим адаптером и добавляем их на // панель displayModeListBox foreach (DisplayMode diplayMode in adapter.SupportedDisplayModes) displayModeListBox.Items.Add(diplayMode); // Выбираем самый первый элемент списка видеорежимов displayModeListBox.SelectedIndex = 0; } // Возвращает информацию о графическом режиме, выбранном пользователем public DisplayMode SelectedDisplayMode { get { return (DisplayMode)displayModeListBox.SelectedItem; } } }
И наконец, в обработчик события Load главной формы необходимо вставить код взаимодействия с диалоговым окном выбора видеорежима: Листинг 3.10. 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 = true; presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; // Создаем диалоговое окно выбора видеорежима using (DisplayModeForm displayModeForm = new DisplayModeForm(GraphicsAdapter.DefaultAdapter)) { // Отображаем диалоговое окно displayModeForm.ShowDialog(); // Получаем видеорежим, выбранный пользователем DisplayMode mode = displayModeForm.SelectedDisplayMode; // Задаем требуемый видеорежим presentParams.BackBufferWidth = mode.Width; presentParams.BackBufferHeight = mode.Height; presentParams.BackBufferFormat = mode.Format; presentParams.FullScreenRefreshRateInHz = mode.RefreshRate; } ... // Создаем графическое устройство device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, options, presentParams);
}
Оставшийся код приложения не содержит чего-либо интересного, поэтому мы не будем на нем останавливаться. Готовое приложение находится в каталоге Examples\Ch03\Ex05. Запустите полученное приложение и попробуйте поэкспериментировать с различными разрешениями экрана. Очень скоро вы заметите одну важную особенность: в некоторых видеорежимах диск вместо круглой формы растягивается/сплющивается в эллипсовидную фигуру. После внимательного изучения соотношения сторон разрешений экрана мы прейдем к выводу, что диск корректно отображается лишь при условии совпадения отношения сторон монитора с отношением соответствующих размерностей разрешения экрана. Например, отношение сторон моего монитора NEC MultiSync FE991SB равно 4:3 = 1.33, поэтому в разрешениях 320×240, 640×480, 800×600, 1024×768, 1280×960, 1600×1200, 1792×1344 изображение отображается без искажений. В разрешении 1280×1024 (соотношение сторон 5:4=1.25) круг едва заметно сплющивается вдоль оси Y, а вот в разрешении 1360×768 (соотношение сторон 16:9=1.77) сплющивание круга вдоль оси X становится более чем заметным. С другой стороны, на широкоэкранных мониторах с соотношением сторон 16:9 именно разрешения 1088×612, 1280×768, 1360×768 и 1600×900 (т.е. с соотношением сторон 16:9) будут давать изображение без искажений.
Рисунок 3.8. Искажение формы круглого диска, визуализированного в разрешении 1360×768, при отображение на мониторе с соотношение сторон 4:3.
Чем обусловлено данное явление? Для борьбы с искажениями наше приложение использует область визуализации квадратной формы. Однако если хорошо подумать, равенство ширины и высоты области визуализации ещѐ не гарантирует квадратной области визуализации, так как необходимо ещѐ одно условие: соотношение сторон разрешение экрана должно совпадать с соотношением сторон экрана монитора. Если эта не так, квадратная область визуализации в действительности окажется неквадратной и форма изображения будет искажена. П р им еч а н ие Обратите внимание на отсутствие в списке видеорежимов с форматами пикселей с поддержкой альфа канала. Дело в том, что коллекция SupportedDisplayModes содержит только видеорежимы экранного буфера, а экранный буфер не поддерживает альфа-канал по причине банальной ненужности (см. таблицу 3.1 в разделе 3.2). Так что отсутствие поддержки альфа-канала экранным буфером вовсе не означает отсутствие поддержки форматов с альфа-каналом задним буфером. Более того, если современная видеокарта поддерживает формат SurfaceFormat.Bgr32, то она с вероятность 99.9% поддерживает и формат SurfaceFormat.Color. Впрочем, эти нюансы для нас пока не актуальны, так как наши текущие приложения все равно не используют альфа-канал.
3.2.3. Диалоговое окно выбора видеорежима. Диалоговое окно выбора видеорежима из примера Ch03\Ex05 хотя и выполняет свою задачу, однако имеет при этом ряд существенных недостатков, затрудняющих его применение в реальных приложениях: 36. При каждом запуске приложения приходится заново выбирать видеорежим, что довольно быстро начинает раздражать. Поэтому было бы логичным реализовать сохранение параметров выбранного видеорежима в конфигурационный файл. 37. Все параметры видеорежимов “свалены в одну кучу”, отчего очень быстро начинает “рябить в глазах”. Коммерческие приложения обычно разбивают информацию о видеорежимах на три списка: в первом списке пользователь выбирает разрешение экрана, во втором – глубину цвета (формат пикселей), а в третьем – частоту обновления экрана. 38. Для пользователя актуальны только те видеорежимы, соотношение сторон которых совпадает с соотношением сторон монитора. С другой стороны, для оценки в уме соотношения сторон видеорежима вроде 1792×1344 требуются незаурядные математические способности, поэтому было бы логичным
предусмотреть типовые фильтры 4:3, 5:4 и 16:9, отбрасывающие видеорежимы с соотношением сторон отличным заданного50. 39. Не помешало бы на всякий случай предоставить пользователю возможность выбора визуализации в оконном режиме, который иногда оказывается весьма полезным (например, при одновременной работе с несколькими приложениями). Ну что ж, настало время разработать новую версию диалогового окна, свободную от этих недостатков (рисунок 3.9). Запустите Visual Studio 2005 и создайте приложение Windows Application. Мы начнем с создания класса, инкапсулирующего работу с файлами конфигурации приложения. Наш файл конфигурации будет содержать 7 полей: Таблица 3.3. Поля файла конфигурации. Поле
Тип данных
Описание
Значение по умолчанию
Width
int
размер заднего буфера вдоль оси X
0
Height
int
размер заднего буфера вдоль оси Y
0
Format
SurfaceFormat
формат пикселей заднего буфера
SurfaceFormat.Unknown
RefreshRate
int
частота обновления экрана
0
FullScreen
bool
работает ли приложение в полноэкранном режиме (false – нет, true – да)
true
Init
bool
равен true, если информация о конфигурации приложения была загружена из файла
false
ShowSettingForm
bool
надо ли отображать при запуске приложения диалоговое окно выбора видеорежима
true
Так как наш файл конфигурации не будет представлять собой нечего экстравагантного, все операции по созданию класса вполне можно выполнить в дизайнере Visual Studio. Для этого в окне Solution Explorer раскройте узел Properties, и сделайте двойной щелчок левой кнопкой мыши на узле Setting.settings, после чего слева откроется дизайнер файла конфигурации приложения 51. Добавьте в настройки приложения новые поля согласно таблице 3.3 и рисунок 3.10.
Рисунок 3.9. Новая версия диалогового окна выбора видеорежима.
50
В принципе, можно было бы запрашивать у пользователя соотношение сторон монитора, после чего масштабировать изображение исходя из параметров разрешения экрана и соотношения сторон монитора (такой подход применяется, к примеру, в игре Half-Life 2). 51 Дизайнер файла конфигурации приложения доступен также посредством вкладки Settings окна Properties.
Рисунок 3.10. Настройка параметров конфигурации приложения
Теперь приступим к созданию диалогового окна настройки параметров приложения. Добавьте приложение новую форму, разместите на ней элементы управления аналогично рисунку 3.9 и задайте их свойства согласно таблице 3.4. Таблица 3.4. Свойства формы диалогового окна и элементов управления. Класс
Свойство
Значение
Form (диалоговое окно)
Name
SettingsForm
Text
Параметры
FormatBorderStyle
FixedDialog
MinimizeBox
false
MaximizeBox
false
Name
inWindowCheckBox
Text
Визуализировать в окне
GroupBox
Text
Видеорежим:
Label
Text
Соотношение сторон:
ComboBox (фильтр отображаемых видеорежимов по соотношению сторон)
Name
aspectRatioComboBox
DropDownStyle
DropDownList
Label
Text
Разрешение:
ComboBox (список разрешений экрана)
Name
resolutionComboBox
DropDownStyle
DropDownList
Label
Text
Глубина цвета:
ComboBox (список форматов пикселей)
Name
colorDepthComboBox
DropDownStyle
DropDownList
Label
Text
Частота обновления
CheckBox
ComboBox (список частот обновления экрана)
Name
refreshRateComboBox
DropDownStyle
DropDownList
Button
Name
okButton
Text
Ok
Text
Cancel
DialogResult
Cancel
Button
Как видно, диалоговое окно содержит списки соотношений сторон экрана, разрешений, глубины цвета (формата пикселей) и частот обновления экрана. Информация в этих списках должна отображаться в дружелюбной форме, ведь неподготовленный пользователь вряд ли сможет без подсказки понять разницу между форматами Bgr32 и Bgr565. Кроме того, информация в списках должна быть отсортирована по некоторому критерию: например, видеорежимы должны перечислять в порядке увеличения разрешения экрана. Чтобы реализовать эти требования придется создать четыре структуры (по одной на каждый список), инкапсулирующие элементы списков. Для этого необходимо добавить в файл диалогового окна код из листинга 3.11. Листинг 3.11. // Инкапсулирует элемент списка соотношений сторон public struct AspectItem { // Коэффициент отношения сторон public float aspect; public AspectItem(float aspect) { this.aspect = aspect; } // Проверяет указанный элемент списка разрешений (ResolutionItem) на соответствие текущему // соотношению сторон. В случае равенства метод возвращает true, иначе - false public bool Compare(float aspect) { // Нулевое соотношение сторон является зарезервированным значением (“любое соотношение // сторон”), поэтому возвращаем true if (this.aspect == 0) return true;
// // // //
Сравниваем коэффициенты отношения сторон, закладывая небольшой “запас” в один процент. Дело в том, что некоторые режимы имеют не совсем “академически” правильное соотношение сторон. Например, соотношение сторон разрешения 1360 x 768 равно 85/48=1.771, в как 16/9=1.778. if (Math.Abs(this.aspect - aspect) < this.aspect * 0.01f) return true; else return false; }
// Возвращает текущее соотношение сторон в удобочитаемом виде public override string ToString() { if (aspect == 0) return "Любое"; if (Compare(4.0f / 3.0f)) return "4 : 3";
if (Compare(5.0f / 4.0f)) return "5 : 4"; if (Compare(16.0f / 9.0f)) return "16 : 9"; return aspect.ToString(); } } // Инкапсулирует элемент списка разрешений экрана public struct ResolutionItem : IComparable { // Разрешение экрана public int width; public int height; public ResolutionItem(int width, int height) { this.width = width; this.height = height; } // Отношение количества пикселей вдоль осей X и Y для текущего разрешения экрана public float Aspect { get { return (float)width / (float)height; } } // Возвращает в удобочитаемом виде текст элемента списка public override string ToString() { return width.ToString() + " x " + height.ToString(); } // Реализация интерфейса IComparable, используемого при сортировке элементов // списка public int CompareTo(ResolutionItem other) { // Сначала сравнивает ширина разрешений if (width > other.width) return 1; if (width < other.width) return -1; // Если ширина одинаковая, сравнивается высота разрешений. В результате разрешения сначала // сортируются по ширине, а затем по высоте. if (height > other.height) return 1; if (height < other.height) return -1; return 0;
} } // Инкапсулирует элемент списка форматов пикселей public struct ColorDepthItem : IComparable { // Формат пикселей public SurfaceFormat value; public ColorDepthItem(SurfaceFormat value) { this.value = value; } // Возвращает строку с описанием формата пикселя public override string ToString() { string str; switch (value) { case SurfaceFormat.Bgr32: str = "32 бита ({0})"; break; case SurfaceFormat.Bgr565: str = "16 бит ({0})"; break; case SurfaceFormat.Rgba1010102: str = "32 бит ({0})"; break; case SurfaceFormat.Bgr555: str = "16 бит {0}"; break; default: str = "{0}"; break; } return string.Format(str, value); } // Реализация интерфейса IComparable, используемого при сортировке элементов // списка public int CompareTo(ColorDepthItem other) { if (value > other.value) return -1; if (value < other.value) return 1; return 0; } } // Инкапсулирует элемент списка частот обновлений экрана public struct RefreshRateItem : IComparable {
// Частота обновления экрана public int value; public RefreshRateItem(int value) { this.value = value; } // Возвращает строку с частотой обновления экрана public override string ToString() { return value.ToString() + " Гц"; } // Реализация интерфейса IComparable< RefreshRateItem>, используемого при сортировке // элементов списка public int CompareTo(RefreshRateItem other) { if (value > other.value) return 1; if (value < other.value) return -1; return 0; } }
Следующий шаг – создание конструктора формы, принимающего в качестве параметров текущие настройки приложения и объект графического адаптера, используемого приложением (листинг 3.12). Листинг 3.12. public partial class SettingsForm : Form { // Набор ассоциативных сортированных массив с информаций о поддерживаемых видеорежимах SortedDictionary>> modeCollection; // Текущие настройки приложения. Класс Properties.Settings автоматически генерируется // дизайнером настроек приложения (рисунок 3.10). Properties.Settings settings; // Конструктор формы internal SettingsForm(Properties.Settings settings, GraphicsAdapter adapter) { InitializeComponent(); // Сохраняем ссылку на настройки приложения this.settings = settings; // Если файл с конфигурацией приложения не был найден, устанавливаем в качестве параметров по // умолчанию настройки рабочего стола if (!settings.Init) { settings.Width = adapter.CurrentDisplayMode.Width; settings.Height = adapter.CurrentDisplayMode.Height; settings.Format = adapter.CurrentDisplayMode.Format; settings.RefreshRate = adapter.CurrentDisplayMode.RefreshRate; settings.FullScreen = true;
} modeCollection = new SortedDictionary>>(); // Перебираем все видеорежимы, поддерживаемые видеокартой и заполняем ассоциативный массив // modeCollection foreach (DisplayMode mode in adapter.SupportedDisplayModes) { ResolutionItem res = new ResolutionItem(mode.Width, mode.Height); if (!modeCollection.ContainsKey(res)) modeCollection.Add(res, new SortedDictionary>()); ColorDepthItem depth = new ColorDepthItem(mode.Format); if (!modeCollection[res].ContainsKey(depth)) modeCollection[res].Add(depth, new SortedDictionary()); RefreshRateItem refresh = new RefreshRateItem(mode.RefreshRate); if (!modeCollection[res][depth].ContainsKey(refresh)) modeCollection[res][depth].Add(refresh, true); } // Задаем состояние флага “визуализировать в окне”. inWindowCheckBox.Checked = !settings.FullScreen; // Добавляем в список “соотношение сторон” типовые фильтры видеорежимов aspectRatioComboBox.Items.Add(new AspectItem()); aspectRatioComboBox.Items.Add(new AspectItem(4.0f / 3.0f)); aspectRatioComboBox.Items.Add(new AspectItem(5.0f / 4.0f)); aspectRatioComboBox.Items.Add(new AspectItem(16.0f / 9.0f)); // Выбираем самый первый элемент списка (”Любое”) aspectRatioComboBox.SelectedIndex = 0; } ... }
Как видно, информация о поддерживаемых видеорежимах хранится в многомерном ассоциативном массиве, упрощающем поиск информации о требуемых видеорежимах. Например, для проверки существования видеорежима с разрешением 1024x768x32bpp:@85Hz приложение должно проверить существование элемента modeCollection[“1024x768”][ SurfaceFormat.Brg32][85]52. Последний оператор конструктора выбирает нулевой элемент списка, генерируя событие SelectedIndexChanged, обработчик которого приведен в листинге 3.13. Листинг 3.13. // Обновляет список поддерживаемых разрешений экрана с учетом нового фильтра соотношения // сторон private void aspectRatioComboBox_SelectedIndexChanged(object sender, EventArgs e) { // Получаем текущий элемент списка “соотношение сторон”
52
В действительности выражение будет несколько более запутанным: modeCollection[new ResolutionItem(1024, 768)][new ColorDepthItem(SurfaceFormat.Bgr32)][new RefreshRateItem(85)]. В прочем, код приложения будет вовсе не таким “ужасным”, так как в качестве индексов будут выступать значения элементов списков разрешения, глубины цвета и частоты обновления экрана.
AspectItem aspect = (AspectItem)aspectRatioComboBox.SelectedItem; // Текущее разрешение экрана ResolutionItem currentResolution; // Используем в качестве текущего разрешения экрана элемент из списка “Разрешение”. Если же // элемент в списке не выбран, используем разрешение экрана из текущих настроек приложения if (resolutionComboBox.SelectedIndex != -1) currentResolution = (ResolutionItem)resolutionComboBox.SelectedItem; else currentResolution = new ResolutionItem(settings.Width, settings.Height); // Очищаем список разрешений экрана resolutionComboBox.Items.Clear(); // Перебираем все разрешения экрана foreach (ResolutionItem res in modeCollection.Keys) // Если разрешение экрана удовлетворяет заданному соотношению сторон if (aspect.Compare(res.Aspect)) // Добавляем его в список разрешений resolutionComboBox.Items.Add(res); // Если было найдено хотя бы одно разрешение экрана, соответствующее заданному соотношению // сторон if (resolutionComboBox.Items.Count != 0) { // В списке разрешений экрана пытаемся выбрать текущее разрешение resolutionComboBox.SelectedItem = currentResolution; // Если это не удалось, выбираем самый первый элемент списка if (resolutionComboBox.SelectedIndex == -1) resolutionComboBox.SelectedIndex = 0; } // Вызываем обработчик события изменения состояния переключателя “Визуализировать в окне”, // который при необходимости активирует/деактивирует определенные элементы формы вроде кнопки // Ok inWindowCheckBox_CheckedChanged(inWindowCheckBox, null); } // Обработчик события CheckedChange флага “Визуализировать в окне”, private void inWindowCheckBox_CheckedChanged(object sender, EventArgs e) { // Если флаг включен if (inWindowCheckBox.Checked) { // Деактивируем все списки, связанные с параметрами полноэкранного режима aspectRatioComboBox.Enabled = false; resolutionComboBox.Enabled = false; colorDepthComboBox.Enabled = false; refreshRateComboBox.Enabled = false; okButton.Enabled = true; } else { // Активируем список “Соотношение сторон” aspectRatioComboBox.Enabled = true; // Если список доступных разрешений экрана не пустой if (resolutionComboBox.Items.Count > 0) { // Активируем оставшиеся три списка и кнопку Ok resolutionComboBox.Enabled = true;
colorDepthComboBox.Enabled = true; refreshRateComboBox.Enabled = true; okButton.Enabled = true; } else { // В противном случае блокируем эти элементы управления resolutionComboBox.Enabled = false; colorDepthComboBox.Enabled = false; refreshRateComboBox.Enabled = false; okButton.Enabled = false; } } }
Так как выбранное разрешение оказывает влияние на доступные форматы пикселей списка “Глубина цвета”, необходимо определить обработчик события SelectedIndexChanged списка разрешений (листинг 3.14). Листинг 3.14. private void resolutionComboBox_SelectedIndexChanged(object sender, EventArgs e) { // Получаем выбранный элемент списка разрешений экрана ResolutionItem res = (ResolutionItem) resolutionComboBox.SelectedItem; // Текущая глубина цвета ColorDepthItem currentColorDepth; // Используем в качестве текущей глубины цвета элемент из списка “Глубина цвета”. Если же // элемент в списке не выбран, используем глубину цвета из текущих настроек приложения if (colorDepthComboBox.SelectedIndex != -1) currentColorDepth = (ColorDepthItem)colorDepthComboBox.SelectedItem; else currentColorDepth = new ColorDepthItem(settings.Format); // Очищаем список “Глубина цвета” colorDepthComboBox.Items.Clear(); // Перебираем все форматы пикселей выбранного разрешения экрана и добавляем их в список // “Глубина цвета” foreach (ColorDepthItem colorDepth in modeCollection[res].Keys) colorDepthComboBox.Items.Add(colorDepth); // Пытаемся выбрать глубину цвета, как у текущих настроек приложения colorDepthComboBox.SelectedItem = currentColorDepth; // В случае неудачи выбираем первый элемент списка if (colorDepthComboBox.SelectedIndex == -1) colorDepthComboBox.SelectedIndex = 0; }
Список поддерживаемых частот экрана, разумеется, тоже зависит от выбранной глубины цвета и разрешения экрана, соответственно, необходимо реализовать и обработчик события SelectedIndexChanged списка глубины цвета (листинг 3.15). Листинг 3.15. private void colorDepthComboBox_SelectedIndexChanged(object sender, EventArgs e) { // Получаем выбранное разрешение и глубину цвета (формат пикселей) ResolutionItem res = (ResolutionItem)resolutionComboBox.SelectedItem; ColorDepthItem colorDepth = (ColorDepthItem)colorDepthComboBox.SelectedItem; // Текущая частота обновления экрана
RefreshRateItem currentRefreshRate; // Используем в качестве текущей частоты обновления экрана элемент из списка. Если же элемент // в списке не выбран, используем частоту обновления экрана из текущих настроек приложения if (refreshRateComboBox.SelectedIndex != -1) currentRefreshRate = (RefreshRateItem)refreshRateComboBox.SelectedItem; else currentRefreshRate = new RefreshRateItem(settings.RefreshRate); // Очищаем список “Частота обновления” refreshRateComboBox.Items.Clear(); // Перебираем частоты обновления экрана, поддерживаемые выбранным разрешением с указанной // глубиной цвета foreach (RefreshRateItem refreshRate in modeCollection[res][colorDepth].Keys) refreshRateComboBox.Items.Add(refreshRate); // Выбираем в списке “Частота обновления” текущую частоту обновления экрана refreshRateComboBox.SelectedItem = currentRefreshRate; // Если такого элемента нет, выбираем самый первый элемент списка if (refreshRateComboBox.SelectedIndex == -1) refreshRateComboBox.SelectedIndex = 0; }
Завершая создание диалогового окна, мы должны реализовать обработчик нажатия кнопки Ok: Листинг 3.16. private void okButton_Click(object sender, EventArgs e) { // Обновляем настройки приложения на основе текущего состояния элементов управления // формы окна settings.FullScreen = !inWindowCheckBox.Checked; if (!inWindowCheckBox.Checked) { settings.Width = ((ResolutionItem)resolutionComboBox.SelectedItem).width; settings.Height = ((ResolutionItem)resolutionComboBox.SelectedItem).height; settings.Format = ((ColorDepthItem)colorDepthComboBox.SelectedItem).value; settings.RefreshRate = ((RefreshRateItem)refreshRateComboBox.SelectedItem).value; } // Настройки были инициализированы. При следующем показа диалогового окна все элементы // управления будут инициализированы согласно значениями сохраненных настроек settings.Init = true; // Пользователь настроил приложение. Показывать диалоговое окно больше нет необходимости. settings.ShowSettingForm = false; DialogResult = DialogResult.OK; }
Интеграция диалогового окна в приложение. И так, у нас есть класс для работы с настройками приложения и диалоговое окно для визуального управления этими настройками. Теперь самое время интегрировать эту функциональность в наше приложение. Для начала мы должны определиться со стратегией поведения приложения, а именно, в каких случаях оно должно отображать диалоговое окно: 40. Вполне логично, что диалоговое окно должно отображаться, если не был найден файл конфигурации. Данную ситуацию можно распознать по свойству Init класса Settings равному true. 41. Если приложение потерпело неудачу при создании графического устройства, то при следующем запуске приложения не помешало бы отобразить диалоговое окно с настройками приложения – вполне вероятно, что изменение видеорежима поможет избежать проблемы. Как такое поведение реализовать на практике? При возникновении проблем приложение должно присвоить свойству ShowSettingForm конфигурации
приложения значение true и завершить работу. А при следующем запуске приложение обнаружит, что свойство ShowSettingForm равно true, и отобразит диалоговое окно с настройками приложения. 42. Наконец, нужен механизм, позволяющий пользователю легко исправить настройки приложения по собственному желанию. Для этой цели можно встроить в обработчик события Load главной формы приложения распознавание ключа /config в параметрах командной строки приложения. Таким образом, инсталлятор приложения наряду с ярлыком запуска приложения может создать дополнительный ярлык “Настройка приложения”, вызывающий это же приложение с параметром /config. Код, реализующий всю вышеперечисленную функциональность, будет иметь достаточно большой размер, поэтому его логично будет инкапсулировать в отдельный метод, чтобы не захламлять обработчик события Load (листинг 3.17). Листинг 3.17. using System.Diagnostics; void InitGraphivsDevice() { SetStyle(ControlStyles.Opaque | ControlStyles.ResizeRedraw, true); MinimumSize = SizeFromClientSize(new Size(1, 1)); // Загружаем настройки приложения из файла settings = new Properties.Settings(); // Определяем, содержит ли командная строка параметр “/config” string[] args = Environment.GetCommandLineArgs(); bool configParam = false; if ((args.Length == 2) && (args[1].ToUpper() == "/CONFIG")) configParam = true; // Если выполняется одно из трех вышеописанных условий if ((configParam) || (settings.ShowSettingForm) || (!settings.Init)) { // Создаем диалоговое окно using (SettingsForm settingsForm = new SettingsForm(settings, GraphicsAdapter.DefaultAdapter)) { // Отображаем диалоговое окно settingsForm.ShowDialog(); // Если командная строка содержит “/config” if (configParam) { // Завершаем работу приложения closing = true; Application.Idle += new EventHandler(Application_Idle); return; } } } presentParams = new PresentationParameters(); presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; // Настраиваем параметры визуализации согласно настройкам приложения presentParams.IsFullScreen = settings.FullScreen; if (settings.FullScreen) {
presentParams.BackBufferWidth = settings.Width; presentParams.BackBufferHeight = settings.Height; presentParams.BackBufferFormat = settings.Format; presentParams.FullScreenRefreshRateInHz = settings.RefreshRate; } else { presentParams.BackBufferWidth = 0; presentParams.BackBufferHeight = 0; presentParams.BackBufferFormat = SurfaceFormat.Unknown; presentParams.FullScreenRefreshRateInHz = 0; } // Проверяем наличие аппаратных вершинных процессоров GraphicsDeviceCapabilities caps = GraphicsAdapter.DefaultAdapter.GetCapabilities( DeviceType.Hardware); CreateOptions options = CreateOptions.SingleThreaded; if (caps.DeviceCapabilities.SupportsHardwareTransformAndLight) options |= CreateOptions.HardwareVertexProcessing; else options |= CreateOptions.SoftwareVertexProcessing; try { // Создаем графическое устройство device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, options, presentParams); } catch (InvalidOperationException) { // Если при создании графического устройства возникли проблемы closing = true; Application.Idle += new EventHandler(Application_Idle); // Отображаем диалоговое окно с предложением изменить параметры визуализации if (MessageBox.Show("Ошибка при создании графического устройства. Изменить ” + "параметры визуализации (разрешение экрана и т.п.)?", "Ошибка", MessageBoxButtons.YesNo, MessageBoxIcon.Error) == DialogResult.Yes) { // При положительном ответе устанавливаем параметр ShowSettingForm в значение true, // указывающий на необходимость показать окно с настройками приложения при следующем запуске settings.ShowSettingForm = true; // Сохраняем настройки приложения в файл settings.Save(); // Перезапускаем приложение Process.Start(Application.ExecutablePath); } return; } } private void MainForm_Load(object sender, EventArgs e) { // Загружаем настройки из файла и создаем графическое устройство InitGraphivsDevice(); // Если приложение завершает работу, выходим из обработчика события Load if (closing) return;
// Остальные действие (создание декларации вершины, заполнение массива вершин, загрузка // эффекта и т.п. ... }
Остальные фрагменты приложения не содержат чего-либо заслуживающего внимания, и вы сможете легко их реализовать самостоятельно. Готовое приложение находится на CD-диске с книгой в каталоге Examples\Ch03\Ex03.
Местоположение файла настроек приложения. .NET Framework 2.0 сохраняет пользовательские настройки в XML–файле user.config, который располагается по достаточно запутанному пути: \\<App Name>_<Evidence Type>_<Evidence Hash>\\user.config
где Profile Directory – каталог локального профиля приложения, обычно имеющий название вроде c:\Documents and Settings\<Имя Пользователя>\Local Settings\Application Data Company Name – строка, формируемая на основе названия компании, заданного атрибутом AssemblyCompany в файле Properties\AssemblyInfo.cs. App Name – строка, формируемая на основе названия приложения, заданного атрибутом AssemblyProduct в файле Properties\AssemblyInfo.cs. Evidence Type, Evidence Hash – вычисляются на основе информации о домене приложения. Version – строка, формируемая на основе версии приложения, заданной атрибутом AssemblyVersion в файле Properties\AssemblyInfo.cs Например, на моем компьютере пример Сh03\Ex06 хранит информацию о конфигурации приложения в файле C:\Documents and Settings\Administrator\Local Settings\Application Data\GSP_Inc\Ex06__Fullscreen_Dialog._Url_31gvfqvzegievbxah0w1qmgu2s2siyo3\1.0.0.0\user.config. Сам файл
имеет простую структуру и легко может быть проанализирован любым продвинутым пользователем: <userSettings> <setting name="Width" serializeAs="String"> 1280 <setting name="Height" serializeAs="String"> 960 <setting name="Format" serializeAs="String"> Bgr32 <setting name="RefreshRate" serializeAs="String"> 85 <setting name="FullScreen" serializeAs="String"> True <setting name="Init" serializeAs="String"> True <setting name="ShowSettingForm" serializeAs="String"> False
Это обстоятельство позволяет редактировать файл в обычном текстовом редакторе, что очень полезно при отладке приложения. Например, в целях повышения “дуракоустройчивости” можно легко протестировать поведение приложения при некорректном значении параметров файла конфигурации.
3.3. Анимация. До сих пор мы визуализировали исключительно статичные изображение, в то время как XNA Framework в первую очередь предназначен для визуализация динамичных сцен с движущимися объектами. Как создать анимированную сцену? Обратимся к мультипликации и кинематографу, в которых эффект движения создается путем вывода на экран последовательности слегка различающихся изображений (кадров). Экспериментально было установлено, что для создания эффекта плавного движения частота смены кадров должна быть порядка 25-ти FPS53. И так, для создания анимации мы должны отображать визуализировать различные фазы движения изображения с частотой 25 кадров в секунду. Наше первое приложение будет визуализировать шарик (точнее диск), летающий о форме и отскакивающий от еѐ стенок (рисунок 3.11). Анимация будет моделироваться посредством таймера (компонент Timer), тикающего с интервалом 40 миллисекунд (то есть 25 раз в секунду). После каждого тика таймера мы будем прибавлять к координатам шарика значение вектора скорости и перерисовывать сцену. При пролѐте шарика сквозь стенку он будет отскакивать обратно, при этом вектор скорости будет изменяться на противоположный. Для придания движениям шарика некоторой неопределенности, модуль вектора скорости будет изменяться на незначительную случайную величину. Основные фрагменты приложения приведены в листинге 3.18.
Рисунок 3.11. Прыгающий шарик.
Листинг 3.18. // Исходный код примера находится на CD с книгой в каталоге Examples\Ch03\Ex07 public partial class MainForm : Form { // Файл эффекта, используемый для визуализации изображения const string effectFileName = "Data\\ColorFill.fx"; // Минимальная скорость диска (“шарика”) const float diskMinSpeed = 0.5f; 53
FPS – Frames Per Second (кадры в секунду)
// Максимальная скорость диска const float diskMaxSpeed = 1.3f; // Количество сегментов в диске const int diskSlices = 32; // Радиус диска const float diskRadius = 0.1f; // Цвет центра диска readonly static XnaGraphics.Color diskInnerColor = XnaGraphics.Color.White; // Цвет края диска readonly static XnaGraphics.Color diskOuterColor = XnaGraphics.Color.Green; // Толщина стенки вдоль границы экрана const float borderSize = 0.1f; // Цвет внутренней границы стенки readonly static XnaGraphics.Color borderInnerColor = XnaGraphics.Color.DarkBlue; // Цвет внешней границы стенки readonly static XnaGraphics.Color borderOuterColor = XnaGraphics.Color.CornflowerBlue; GraphicsDevice device = null; PresentationParameters presentParams; Effect effect = null; VertexDeclaration decl = null; // Массив вершин стены вдоль края формы VertexPositionColor[] borderVerts = null; // Массив вершин диска с центром в начале системы координат VertexPositionColor[] baseDiskVerts = null; // Массив вершин диска, перемещаемого по поверхности экрана VertexPositionColor[] diskVerts = null; FillMode fillMode = FillMode.Solid; // Скорость диска вдоль оси X float speedX; // Скорость диска вдоль оси Y float speedY; // Координата X центра диска float posX = 0; // Координата Y центра диска float posY = 0; // Генератор случайных чисел Random rnd = new Random(); // Конфигурация приложения (разрешение экрана и т.п.) Properties.Settings settings; bool closing = false; // Вычисляет случайную скорость диска, лежащую в диапазоне diskMinSpeed .. diskMaxSpeed float RndSpeed() { return diskMinSpeed + (float)rnd.NextDouble() * (diskMaxSpeed - diskMinSpeed); } // Обработчик события Load главной формы private void MainForm_Load(object sender, EventArgs e) { // Чтение файла конфигурации и создание графического устройства InitGraphivsDevice();
if (closing) return; // Создание декларации вершины decl = new VertexDeclaration(device, VertexPositionColor.VertexElements); // Создание и заполнение массива вершин, визуализируемого с использованием списка // треугольников (PrimitiveType.TriangleStrip) borderVerts = new VertexPositionColor[10]; borderVerts[0] = new VertexPositionColor(new Vector3(-1.0f, -1.0f, 0.0f), borderOuterColor); borderVerts[1] = new VertexPositionColor(new Vector3(-1.0f + borderSize, -1.0f + borderSize, 0.0f), borderInnerColor); borderVerts[2] = new VertexPositionColor(new Vector3(-1.0f, 1.0f, 0.0f), borderOuterColor); borderVerts[3] = new VertexPositionColor(new Vector3(-1.0f + borderSize, 1.0f borderSize, 0.0f), borderInnerColor); borderVerts[4] = new VertexPositionColor(new Vector3(1.0f, 1.0f, 0.0f), borderOuterColor); borderVerts[5] = new VertexPositionColor(new Vector3(1.0f - borderSize, 1.0f borderSize, 0.0f), borderInnerColor);
borderVerts[6] = new VertexPositionColor(new Vector3(1.0f, -1.0f, 0.0f), borderOuterColor); borderVerts[7] = new VertexPositionColor(new Vector3(1.0f - borderSize, -1.0f + borderSize, 0.0f), borderInnerColor); borderVerts[8] = new VertexPositionColor(new Vector3(-1.0f, -1.0f, 0.0f), borderOuterColor); borderVerts[9] = new VertexPositionColor(new Vector3(-1.0f + borderSize, -1.0f + borderSize, 0.0f), borderInnerColor); // Создание диска с центром в начале координат. Диск визуализируется с использованием веера // треугольников (PrimitiveType.TriangleFan) baseDiskVerts = new VertexPositionColor[diskSlices + 2]; baseDiskVerts[0] = new VertexPositionColor(new Vector3(0.0f, 0.0f, 0.0f), XnaGraphics.Color.White); for (int i = 0; i <= diskSlices; i++) { float angle = (float)i / (float)diskSlices * 2.0f * (float)Math.PI; float x = diskRadius * (float)Math.Sin(angle); float y = diskRadius * (float)Math.Cos(angle); baseDiskVerts[i + 1] = new VertexPositionColor(new Vector3(x, y, 0.0f), diskOuterColor); }; // Создаем массив вершин диска путем клонирования. Таким образом, при старте приложения диск // расположен в начале системы координат. diskVerts = (VertexPositionColor[]) baseDiskVerts.Clone();
// Задаем начальную скорость диска вдоль осей X и Y speedX = RndSpeed(); speedY = RndSpeed(); } // Обработчик события Paint главной формы private void MainForm_Paint(object sender, PaintEventArgs e) { ... // Визуализируем сцену effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleStrip, borderVerts, 0, borderVerts.Length - 2); device.DrawUserPrimitives(PrimitiveType.TriangleFan, diskVerts, 0, diskVerts.Length - 2); pass.End(); } effect.End(); device.Present(); ... } // Обработчик события Tick таймера, свойству Interval которого присвоено значение 40 private void timer_Tick(object sender, EventArgs e) { // Изменяем координаты центра диска на расстояние, которое он должен пройти за время между // двумя тиками таймера posX += speedX * (float)timer.Interval * 0.001f; posY += speedY * (float)timer.Interval * 0.001f; // Если диск столкнулся с правым краем границы if (posX >= 1 - diskRadius - borderSize) { // Диск не должен перелетать за границу posX = 1 - diskRadius - borderSize; // Изменяем направление движения диска на противоположное и вычисляем новую скорость. speedX = -Math.Sign(speedX) * RndSpeed(); } // Если диск столкнулся с верхним краем границы if (posY >= 1 - diskRadius - borderSize) { posY = 1 - diskRadius - borderSize; speedY = -Math.Sign(speedY) * RndSpeed(); } // Если диск столкнулся с левым краем границы if (posX <= -1 + diskRadius + borderSize) { posX = -1 + diskRadius + borderSize; speedX = -Math.Sign(speedX) * RndSpeed(); }
// Если диск столкнулся с нижним краем границы if (posY <= -1 + diskRadius + borderSize) { posY = -1 + diskRadius + borderSize; speedY = -Math.Sign(speedY) * RndSpeed(); } // Вычисляем новые координаты диска на основе “эталонного” диска с центром в начале // координат. for (int i = 0; i < baseDiskVerts.Length; i++) { diskVerts[i].Position.X = baseDiskVerts[i].Position.X + posX; diskVerts[i].Position.Y = baseDiskVerts[i].Position.Y + posY; } // Перерисовываем изображение Invalidate(); } }
Расчет координат вершин диска, использующий тригонометрические функции, является весьма ресурсоемкой операцией, поэтому в приложении используется небольшая хитрость. Вместо многократного расчета вершин диска приложение рассчитывает только координаты “эталонного” диска с центром в начале системы координат, которые заносятся в массив baseDiskVerts. После этого для получения вершин заданной окружности необходимо сместить все вершины “эталонной” окружности на расстояние заданной окружности от центра. Запустите приложение на выполнение. Первое что бросится в глаза – это движение окружности рывками. Но ведь этого не может быть! Фильмы то при частоте 25 fps идут очень плавно. В чем же принципиальная разница между фильмом и нашим приложением? Все очень просто. В кинематографе кадры снимается с некоторой экспозицией54. В результате каждый кадр фильма содержит усреднѐнное изображение за период экспозиции, что проявляется в смазывании быстродвижущихся объектов. Таким образом, 25 FPS в кинофильме и 25 FPS в нашем приложении – это немного разные FPS. В частности, в компьютерных играх при частоте кадров 25-30 FPS ощущаются некие “подѐргивания” в динамичных сценах – объекты движутся как бы рывками. Эту проблему решают методом грубой силы, то есть простым увеличением частоты кадров – практический опыт показывает, что в большинстве случаев вполне достаточно частоты кадров 60 FPS. До по л нит ел ь на я и н фо р ма ц ия Для визуализации быстродвижущихся объектов вроде спортивного автомобиля, пролетающего мимо наблюдателя со скоростью 300 км/ч, даже частота кадров 60 FPS оказывается недостаточной. С другой стороны, при попытке наращивания FPS мы упираемся в возможности аппаратуры – максимальная частота вертикальной развѐртки современных мониторов (то есть частота смены кадров) редко превышает 60-100 Hz. Поэтому для повышения реалистичности движений применяют технологии Motion Blur55, которые различными способами пытаются имитировать экспозицию при “киносъѐмке” кадров изображения.
Ну что ж, попробуем увеличить частоту смены кадров. Как известно, компонент таймер может тикать до 64х раз в секунду, т.е. минимальный интервал между двумя тиками равен 0.015 миллисекунд. Присвойте свойству Interval таймера значение 15 и снова запустите приложение. Шарик будет двигаться ощутимо плавнее, однако скорость не будет постоянной: периодически шарик будет ни с того то замедляться, то ускоряться. Почему это происходит? Для ответа на этот вопрос необходимо внимательно прочитать описания таймера Windows в MSDN. Хотя таймер и может тикать с интервалом 15 миллисекунд, точность каждого тика составляет 55 миллисекунд. Таким образом, по мере уменьшения интервала между тиками, точность таймера катастрофически падает, что приводит к неравномерности движения объектов. Но есть ещѐ один немаловажный фактор – события от таймера имеют самый низкий приоритет среди всех сообщений. Например, если пользователь в это время нажимает клавишу клавиатуры, а в очереди сообщений находится необработанное сообщение WM_TIMER, то сообщения WM_KEYDOWN, WM_CHAR и WM_KEYUP будут вставлены в очередь перед сообщением WM_TIMER. По сути, сообщение WM_TIMER обрабатывается только при условии пустой очереди событий потока, а так как в очереди может находиться не более одного сообщения
54 55
Время, в течение которого объектив аппарата остается открытым при фото- и киносъемке. Эффект размывания изображения быстро движущегося объекта
таймер может “терять” тики56. Чтобы убедиться в этом, попробуйте ухватиться указателем мыши за край формы и поизменять еѐ размер, что тут же приведет к потере сообщений и, соответственно, замедлению шарика. WM_TIMER,
Эти недостатки компонента Timer затрудняют его применение в реальных игровых приложениях. Впрочем, данный компонент предназначен для реализации задач, не критичных к точности срабатывания таймера, вроде автоматического сохранения документа через заданные интервалы времени или обновления текущего времени на форме.
3.3.1. Использование события Idle. Итак, использование обычного компонента Timer не увенчалось успехом из-за ряда ограничений системного таймера Windows. Кроме того, использование таймера для визуализации сцены обладает ещѐ одним фундаментальным ограничением. Дело в том, что, задавая определенную частоту визуализации кадров, мы делаем неявное предположение, что абсолютно любой компьютер может визуализировать сцену с требуемой частотой кадров. Если это вдруг окажется не так, приложение будет работать в «замедленном» режиме. Учитывая, что производительность видеоподсистем различных компьютеров может отличаться в десятки раз, возникновение подобной проблемы более чем вероятно. Что же делать? Раз визуализация сцены с фиксированной частотой кадров чревата возникновением множества проблем, надо просто визуализировать кадры с максимально возможной частотой, для чего достаточно поместить в обработчик события Idle вызов метода Invalidate. Для измерения временных интервалов между вызовами обработчика события Idle можно воспользоваться свойством System.Enviroment.TickCount, возвращающим количество миллисекунд, прошедших с момента загрузки операционной системы. Основные фрагменты нового варианта кода приведены в листинге 3.19. Листинг 3.19. // Пример Examples\Ch03\Ex08 public partial class MainForm : Form { // Значение свойства System.Enviroment.TickCount во время последнего вызова обработчика // события Idle. int lastTick; private void MainForm_Load(object sender, EventArgs e) { ... // Запоминаем текущее значение свойства System.Enviroment.TickCount lastTick = Environment.TickCount; Application.Idle += new EventHandler(Application_Idle); } void Application_Idle(object sender, EventArgs e) { // Если приложение завершает работу, закрываем главную форму. if (closing) { Close(); return; } int currentTick = Environment.TickCount; // Вычисляем время (в секундах), прошедшее между двумя вызовами обработчика Idle float delta = (float)(currentTick - lastTick) * 0.001f; // Изменяем положение диска posX += speedX * delta; posY += speedY * delta;
56
Особенности обработки сообщений Windows подробно описаны в [К.28].
// Обрабатываем столкновение с стеной вдоль границы экрана ... // Запоминаем текущее время с момента загрузки операционной системы lastTick = currentTick; // Перерисовываем экран Invalidate(); } ... }
Шарик примера Ch03\Ex08 движется ощутимо плавнее, однако небольшие, еле заметные рывки все же остались. Для определения причины рывков вставьте в обработчик события Idle команду Trace.WriteLine(delta), запустите приложение, вернитесь в Visual Studio и посмотрите содержимое окна Output. Скорее всего, его содержимое будет выглядеть примерно следующим образом: delta delta delta delta delta delta delta delta delta delta delta
= = = = = = = = = = =
0,078 0 0 0,016 0,015 0 0,016 0,015 0,016 0 0,016
Обратите внимание на множество нулевых значений параметра delta, соответствующих полной остановке шарика на месте, которая воспринимается пользователем как небольшое подергивание. Почему между некоторыми вызовами события Idle проходит 15-16 миллисекунд, а между другими – меньше одной миллисекунды. Подобный разброс производительности не выглядит правдоподобным, значит дело в чем-то ином. Для выяснения причин этой аномалии придется обратиться к MSDN. Метод Environment.TickCount использует функцию Win32 GeTickCount, точность которой порядка 10 миллисекунд. Соответственно, при попытке измерить временной интервал близкий к 10 миллисекундам метод Environment.TickCount может вернуть два одинаковых значения, что мы и наблюдаем.
3.3.2. Использование высокоточного таймера Stopwatch. Так как точность измерения времени посредством свойства Environment.TickCount зачастую оказывается недостаточной, Microsoft по многочисленным просьбам трудящихся добавил в .NET Framework 2.0 класс System.Diagnostics.Stopwatch, предназначенный для высокоточного измерения временных интервалов. Данный класс весьма прост в использовании. Сначала приложение должно создать экземпляр класса Stopwatch и запустить таймер методом Start. Затем по мере необходимости приложение посредством свойства ElapsedMilliseconds получает количество миллисекунд, прошедшее с момента запуска таймера. Когда же все измерения времени выполнены, приложение останавливает таймер командой Stop. Для оценки точности таймера Stopwatch можно воспользоваться статическим свойством Frequency, возвращающее количество тиков высокоточного таймера, используемого классом Stopwatch для измерения временных интервалов, за 1 секунду. На моем компьютере свойство Frequency равно 1.870.000.0000, что соответствует фантастической точности 1/18700000000 = 5∙10-10 сек. Так как метод ElapsedMilliseconds по определению не может изменять интервалы меньше 1 миллисекунды, в классе Stopwatch имеется один метод ElapsedTicks, возвращающий количество тиков таймера. Для перевода тиков в секунды значение свойства ElapsedTicks надо поделить на Stopwatch.Frequency. При этом следует всегда помнить о том, что при измерении сверхкоротких интервалов появляются иные погрешности, вызванные переключением задач, кэш промахами, длинным конвейером процессора, накладными затратами на вызовы функций и т.п., в результате чего предельная точность измерений таймера Stopwatch редко достижима на практике.
Код приложения, переписанный с использованием таймера Stopwatch приведен в листинге 3.20. Листинг 3.20. // Пример Examples\Ch03\Ex09 public partial class MainForm : Form { // Высокоточный таймер Stopwatch stopwatch; // Время, прошедшее с момента запуска таймера при последнем вызове обработчика события // Idle long lastTime; private void MainForm_Load(object sender, EventArgs e) { ... // Запускаем таймер stopwatch = new Stopwatch(); stopwatch.Start(); lastTime = 0; // Выводим в окно Output точность таймера Trace.WriteLine("Accuracy = " + 1.0 / Stopwatch.Frequency + " sec"); Application.Idle += new EventHandler(Application_Idle); } void Application_Idle(object sender, EventArgs e) { // Если приложение завершает работу, закрываем главную форму. if (closing) { Close(); return; } // Получаем количество миллисекунд, прошедших с момента запуска таймера double currentTime = (double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency; // Вычисляем время (в секундах), прошедшее между двумя вызовами обработчика события Idle float delta = (float)(currentTick - lastTick); // Изменяем положение диска posX += speedX * delta; posY += speedY * delta; // Обрабатываем столкновение с краями экрана ... // Запоминаем текущее показание таймера lastTime = currentTime; // Перерисовываем экран Invalidate(); } ... }
Запустив приложение, вы убедитесь, что в окне Output пропали нулевые значения. В полноэкранном режиме содержимое окна Output скорее всего будет выгладить примерно следующим образом: Accuracy = 5,3475935828877E-10 sec
delta delta delta delta delta delta delta delta delta delta delta
= = = = = = = = = = =
0,035 0,006 0,003 0,002 0,011 0,012 0,012 0,012 0,011 0,013 0,011
П р им еч а н ие Обратите внимание на ощутимую задержку при визуализации первого кадра, обусловленную накладными расходами JIT-компилятора при компиляции метода Paint.
В результате шарик наконец-то стал двигаться по-настоящему плавно. Однако в оконном режиме рывки попрежнему остались, при этом время между визуализацией соседних кадров скачкообразно меняется почти в два раза: Accuracy = 5,3475935828877E-10 sec delta = 0,035 delta = 0,022 delta = 0,013 delta = 0,032 delta = 0,015 delta = 0,032 delta = 0,014 delta = 0,032 delta = 0,016 delta = 0,032 delta = 0,014 delta = 0,016 delta = 0,019
Все дело в вертикальной синхронизации, которая будет рассмотрена в следующем разделе.
3.3.3 Управление вертикальной синхронизацией. Как известно, CRT-мониторы формируют изображение посредством пучка электронов, который построчно пробегает по поверхности экрана сверху вниз, заставляя еѐ светиться. Поскольку свечение экрана быстро тускнеет57, этот процесс повторяется снова и снова с частотой вертикальной развертки текущего видеорежима режима. Теперь давайте представим, что произойдет, если изображение изменится в процессе обратного хода луча. И так, часть верхняя изображения уже отображена на экране, но прервать процесс формирования изображения не возможно, поэтому электронный луч продолжит сканировать поверхность экрана, формируя новое изображение. В результате на экране кратковременно будут находиться новое и старое изображение, что воспринимается пользователем как странный артефакт. Для борьбы с этим недоразумением XNA Framework предоставляет программисту возможность управления синхронизацией переключения заднего и экранного буферов с моментом окончания формирования электронным лучом изображения на экране монитора. Эта функциональность доступна посредством свойства PresentationInterval класса PresentationParameters: public PresentInterval PresentationInterval { get; set; }
В подавляющем большинстве случаев свойству PresentationInterval присваивают одно из следующих трех значений перечислимого типа PresentInterval:
PresentInterval.Default – значение по умолчанию, аналогичное PresentInterval.One.
PresentInterval.One – буферы переключаются только по окончанию обновления электронным лучом изображения на экране монитора.
57
Длительность послесвечения люминофора современных трубок около 1 мс. [С.13].
PresentInterval.Immediate – кадровые буферы переключаются так быстро, насколько это возможно.
Хотя значения PresentInterval.Default и PresentInterval.One формально являются братьями-близнецами, между ними есть одна очень тонкая разница. При использовании PresentInterval.Default синхронизации с обратным ходом луча осуществляется посредством обычного таймера Windows, который, как вы помните, обладает очень низкой точностью. Из-за особенностей организации оконной подсистемы Windows низкая точность таймера приводит к частым задержкам при переключении буферов и, соответственно, “рывкам” при анимации (что мы и наблюдали в разделе 3.3.2). В полноэкранном режиме последствия, как правило, не столь серьезны. В отличии от PresentInterval.Default, значение PresentInterval.One использует высокоточный таймер, что позволяет избавиться от рывков. Тем не менее, в некоторых случаях незначительные рывки всѐ же могут остаться: так как видеокарта перед переключением кадровых буферов ожидает завершение визуализации текущего кадра, нетрудно догадаться, что при самом не благоприятном стечении обстоятельств частота смены кадров может оказаться в два раза меньше частоты вертикальной развертки монитора. Да и драйверы видеокарты зачастую оказываются не такими совершенными, как хотелось бы. При возникновении подобных проблем пользователь может попробовать отключить вертикальную синхронизацию, так как резкие скачки частоты смены кадров раздражают гораздо сильнее артефактов из-за отсутствия вертикальной синхронизации. Итак, однозначно правильного режима смены кадров не существует, поэтому было бы разумным предоставить этот выбор пользователю. Для этой цели я поместил на форму диалогового окна Параметры (которое мы создали в разделе 3.2.3) дополнительный флажок Вертикальная синхронизация (vsynchCheckBox), установка которого соответствует использованию режима PresentInterval.One, а снятие – PresentInterval.Immediate (рисунок 3.12). Для сохранения значения режима вертикальной синхронизации в настройки приложения было добавлено дополнительное поле PresentationInterval типа Microsoft.Xna.Framework.Graphics.PresentInterval, а сам код приложения подвергся косметической доработке (листинг 3.21).
Рисунок 3.12. Диалоговое окно Параметры с новым флажком Вертикальная синхронизация.
Листинг 3.21. // Готовое приложение находится на CD-диске книги в каталоге Examples\Ch03\Ex10 // Класс главной формы приложения public partial class MainForm : Form { void InitGraphivsDevice() { ... presentParams = new PresentationParameters(); presentParams.BackBufferCount = 1; presentParams.SwapEffect = SwapEffect.Discard; presentParams.IsFullScreen = settings.FullScreen;
// Устанавливаем режим вертикальной синхронизации согласно настройкам приложения presentParams.PresentationInterval = settings.PresentationInterval; ... } ... } // Класс диалогового окна “Параметры” public partial class SettingsForm : Form { // Конструктор диалогового окна internal SettingsForm(Properties.Settings settings, GraphicsAdapter adapter) { ... // Устанавливает значение флажка “Вертикальная синхронизация” согласно настройкам приложения if (settings.PresentationInterval == PresentInterval.One) vsynchCheckBox.Checked = true; else vsynchCheckBox.Checked = false; } // Обработчик нажатия кнопки Ok private void okButton_Click(object sender, EventArgs e) { ... // Сохраняем в настройках приложения информацию о режиме кадровой синхронизации if (vsynchCheckBox.Checked) settings.PresentationInterval = PresentInterval.One; else settings.PresentationInterval = PresentInterval.Immediate; ... } }
Запустите приложение и попробуйте поэкспериментировать с настройками кадровой синхронизации, наблюдая за окном Output. При включенной вертикальной синхронизации интервал между визуализацией кадров будет примерно равен константе 1.0/{частота обновления экрана текущего видеорежима}. При отключении вертикальной синхронизации частота интервал смены кадров уменьшится до величины сопоставимой с 1 мс, что соответствует частоте порядка 1000 fps. При этом и в том и в другом случае движения шарика будут плавными.
3.3.4. Замена циклов foreach на for. При визуализации диска проходы (Passes) эффекта перебираются посредством цикла foreach. Достоинствами цикла foreach является простота кода и защита от потенциальных ошибок вроде использования неправильного индекса при обращении к элементу коллекции. Обратной стороной является неявное использование циклом foreach специального типа, реализующего интерфейс Enumerator. Например, код List numbers = new List(3); ... int s = 0; foreach (int v in numbers) s += v;
неявно заменяется компилятором следующим аналогом: List numbers = new List(3); ...
int s = 0; // Создается объект enumerator IEnumerator enumerator = numbers.GetEnumerator(); // Перебирает элементы коллекции while (enumerator.MoveNext()) // Получает доступ к текущему элементу коллекции s += (int)enumerator.Current;
На первый взгляд, данный код является неэффективным. Но в действительности всѐ не так уж и плохо. Вопервых, метод GetEnumerator возвращает структуру: public class List : IList, ICollection, IEnumerable, IList, ICollection, IEnumerable { public Enumerator GetEnumerator() { return new Enumerator((List) this); } ... public struct Enumerator : IEnumerator, IDisposable, IEnumerator { private List list; private int index; private int version; private T current; internal Enumerator(List list); public void Dispose(); public bool MoveNext(); public T Current { get; } object IEnumerator.Current { get; } void IEnumerator.Reset(); } }
Соответственно, вызов метода GetEnumerator не приводит к выделению памяти в управляемой куче и учащению вызовов сборщика мусора. Во-вторых, компилятор C# вызывает метод MoveNext и свойство Current структуры Enumerator напрямую без приведения к ссылке на интерфейс IEnumerator, так что боксирование (boxing) структуры Enumerator не происходит. И, наконец, компилятор может встроить (inline) код метода MoveNext и свойства Current непосредственно в код цикла, избежав накладных расходов на вызовы методов. Так что при использовании массивов или коллекций наподобие List разница в производительности циклов foreach и for будет исключающее мала, и в подавляющем большинстве случаев на неѐ можно не обращать внимания. Однако при использовании других коллекций не всѐ так просто. Например, у некоторых объектов метод GetEnumerator возвращает объект, размещаемых в управляемой куче. Подобный подход имеет два недостатка: 43. Частый вызов цикла foreach для перебора элементов такой коллекции приведет к созданию множества объектов enumerator, и, соответственно, частому вызову сборщика мусора, что привет к падению производительности. 44. Если метод MoveNext является виртуальным, то компилятор не сможет встроить его непосредственно в код цикла, что тоже негативно скажется на производительности. Таким образом, в критичных к производительности приложениях при переборе элементов коллекций, возвращающих объект enumerator, имеет смысл избегать циклов foreach. Но давайте вернемся к коллекции Passes класса Effect. Данная коллекция реализуется классом EffectPassCollection, объявленным следующим образом:
public sealed class EffectPassCollection : IEnumerable<EffectPass> { // Список для хранения информации о проходах private List<EffectPass> pPass; // Возвращает объект (полученный путем боксирования структуры), реализующий интерфейс // IEnumerator<EffectPass> public IEnumerator<EffectPass> GetEnumerator() { return (IEnumerator<EffectPass>) this.pPass.GetEnumerator(); } }
Давайте внимательно рассмотрим этот код. Класс EffectPassCollection в действительности хранит информацию о проходах в коллекции List. Соответственно метод this.pPass.GetEnumerator возвращает ссылку на структуру Enumerator класса List, определение которой было приведено в начале раздела. И всѐ бы было просто замечательно, если бы не один маленький нюанс - структура IEnumerator приводится к интерфейсу IEnumerator<EffectPass>, что приводит к боксированию и выделению памяти в управляемой куче. Чтобы оценить влияние этой особенности на производительность приложения, мы встроим в обработчик события Paint код, перебирающий элементы коллекции 10.000.000 раз посредством циклов for и foreach с измерением времени их выполнения (листинг 3.22). Листинг 3.22. // Пример Examples\Ch03\Ex11 #define TEST #if TEST bool testing = true; #endif private void MainForm_Paint(object sender, PaintEventArgs e) { // Измерение выполняется только при условии определения идентификатора TEST #if TEST // Тест выполняется только один раз if (testing) { // Число итераций const int n = 10000000; int sum; // Выводим количество сборок мусора, выполненных на момент начала эксперимента Trace.WriteLine("Gen 0 collection count: " + GC.CollectionCount(0)); Stopwatch timer = new Stopwatch(); // Запускаем таймер timer.Start(); // Перебираем элементы коллекции passes и суммируем коды первой буквы sum = 0; EffectPassCollection passes = effect.CurrentTechnique.Passes; // Выполняем n итераций for (int i = 0; i < n; i++) // Перебираем элементы коллекции passes посредством for и суммируем коды первой буквы for (int j = 0; j < passes.Count; j++) sum += (int)passes[j].Name[0]; // Вычисляем суммарное время, затраченное на интеграции double time1 = (double)timer.ElapsedTicks / (double)Stopwatch.Frequency;
// Отображаем отчет Trace.WriteLine("-----------for------------"); Trace.WriteLine("Sum : " + sum.ToString()); Trace.WriteLine("Gen 0 collection count: " + GC.CollectionCount(0)); Trace.WriteLine("Time1 : " + time1); Trace.WriteLine(""); // Перезапускаем таймер timer.Reset(); timer.Start(); sum = 0; // Снова выполняем итерации, но уже посредством цикла foreach for (int i = 0; i < n; i++) foreach (EffectPass pass in effect.CurrentTechnique.Passes) sum += (int)pass.Name[0]; // Вычисляем суммарное время, потраченное на итерации double time2 = (double)timer.ElapsedTicks / (double)Stopwatch.Frequency; // Отображаем отчет Trace.WriteLine("---------foreach----------"); Trace.WriteLine("Sum : " + sum.ToString()); Trace.WriteLine("Gen 0 collection count: " + GC.CollectionCount(0)); Trace.WriteLine("Time2 : " + time2); // Определяем разницу в производительности Trace.WriteLine("Time2 / Time1 = " + time2 / time1); Trace.WriteLine(""); testing = false; } #endif }
Запустив модифицированное приложение на своем компьютере с процессором Intel Pentium-4 2.8C я получил следующие результаты: Accuracy = 3,5730747380043E-10 sec Gen 0 collection count: 3 -----------for-----------Sum : 1120000000 Gen 0 collection count: 3 Time1 : 0,554915015846586 ---------foreach---------Sum : 1120000000 Gen 0 collection count: 915 Time2 : 1,84470303389776 Time2 / Time1 = 3,32429828211344 Как видно, в процессе работы цикла foreach сборщик мусора был вызван 915 раз58, а разница в производительности между циклами for и foreach достигла трехкратной величины. Таким образом, 58
Результаты, полученные на других компьютерах, могут заметно отличаться. Так на моем втором домашнем компьютере с процессором Intel Core2 Due E6300 при выполнении циклов foreach сборщик мусора был вызван 229 раз. В целом же, частота вызовов сборщика мусора в первую очередь зависит от
перебор элементов коллекции foreach в методе Paint, вызываемом около сотни раз в секунду, является не самой лучшей идеей. Особенно если учесть, что реальные приложения зачастую содержат десятки эффектов, а внезапная сборка мусора при визуализации кадра может привести заметному провалу производительности. П р им еч а н ие На самом деле Microsoft здорово поработала над эффективностью сборщика мусора, и сейчас сборка мусора в поколении 0 обычно занимает порядка 1 мс. В частности, пренебрегая накладными затратами цикла foreach, можно прикинуть, что в нашем эксперименте каждая сборка мусора выполнялась в среднем не более чем за (1.84 – 0.55) / 915 = 0.0014 секунд. Тем не менее, рано или поздно частые сборки мусора могут спровоцировать сбор мусора в старших поколениях, который будет воспринят пользователем как внезапное “подтормаживание” приложения. Более подробную информацию о работе сборщика мусора можно найти в [К.27], [С.14] и [С.15].
Проект
приложения,
переписанного
без
использования
цикла
foreach,
находится
в
каталоге
Examples\Ch03\Ex11.
3.3.5. Устранение зависимости движений диска от производительности компьютера. Итак, после всех наших трудов движения диска стали по-настоящему плавными. Тем не менее, наше приложение всѐ ещѐ далеко от совершенства. Давайте попробуем представить, что произойдет, если приложение вдруг внезапно приостановит свою работу на несколько секунд, например, из-за повысившейся активности работы с файлом подкачки, плохо читаемого сектора на диске или какой-либо проблемы в драйвере устройства. После возобновления выполнения приложения оно экстраполирует прямолинейное движение диска на несколько секунд вперед и обнаружит, что он уже далеко вылетел за пределы ограничивающей стены. После чего шарик будет автоматически возращен обратно в один из углов прямоугольной границы. Разумеется, траектория движения диска при этом окажется нарушенной, ведь за это время диск должен был уже несколько раз отскочить от стены. Подобный эффект в меньшей степени проявляется и при незначительных провалах производительности, что в конечном счете, приводит несколько различному поведению приложения на разных компьютерах. Вообще данная особенность не является большим недостатком для нашего приложения, в конце концов, диск ведь движется по случайной траектории. Однако в реальных игровых приложениях такая реакция на внезапные провалы производительности неминуемо приведет к разнообразным “глюкам” игровой логики (проход сквозь препятствия и т.п.). Причем чем ниже производительность компьютера, тем вероятнее возникновение проблем. В принципе для ликвидации данного недостатка можно было бы найти аналитическое выражение зависимости координат центра диска от времени. Однако такое решение будет пригодно лишь для самых простых случаев. Поэтому мы реализуем более универсальный подход: моделирование движений диска с некоторым фиксированным шагом, например, 0.005 секунды. Реализация данной технологии потребует лишь косметической правки обработчика события Idle (листинг 3.23). Листинг 3.23. // Дискретный шаг времени (в секундах), с которым выполняются расчеты const float timeStep = 0.005f; // Максимальный временной интервал между двумя вызовами обработчика события Idle (в секундах) const float maxDelta = 5.0f; void Application_Idle(object sender, EventArgs e) { ... // Определяем текущее время double currentTime = (double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency; // Если время между двумя вызовами обработчика Idle превышает maxDelta, корректируем lastTime // во избежание слишком длинной работы обработчика события Idle if (currentTime - lastTime > maxDelta) lastTime = currentTime - maxDelta;
размера кэша второго уровня CPU. Например, размер кэша процессора Intel Core2 Due E6300 (2MB) в четыре раза больше, чем у Pentium-4 2.8C (512KB). Соответственно, сборщик мусора на Intel Core2 Due E6300 вызывается в 4 раза реже по сравнению с Pentium-4 2.8C (915/229 = 3.996).
// Моделируем движения шарика дискретным шагом времени timeStep while (lastTime + timeStep < currentTime) { posX += speedX * timeStep; posY += speedY * timeStep; ... lastTime += timeStep; } Invalidate(); }
В самых запущенных случаях задержка между визуализацией кадров может достигать минуты и даже больше. Так как моделирование игровой логики интервала времни в несколько минут может занять достаточно существенно время, в приложении используется простой прием – игровая логика рассчитывается не более чем за 5 секунд игрового времени (константа maxDelta). Для пользователя подобный трюк является незаметным, ведь он все равно не сможет спрогнозировать поведение приложения на значительный временной интервал. П р им еч а н ие В интерактивных приложениях вроде автосимуляторов при возникновении длительной задержки между кадрами имеет смысл приостановить игровой процесс, ведь в течение задержки в несколько секунд автомобиль игрока наверняка слетит с трассы или столкнется с препятствием. Эффекта автоматической приостановки игрового процесса можно достичь путем присвоения константе maxDelta небольшого значения порядка 0.2 - 0.5 секунд.
3.4. Визуализация полупрозрачных примитивов. В компьютерной графике довольно часто приходится моделировать различные полупрозрачные объекты вроде стекла, воды, тумана и т.п. Полупрозрачность – это характеристика объекта, показывающая, какую часть света пропускает среда (объект) без изменения направления его распространения. Так полностью прозрачный объект пропускает через себя весь свет, в результате чего не оказывает никакого влияния на находящиеся позади него объекты (иными словами, он является невидимкой). Непрозрачный объект напротив не пропускает через себя свет, и соответственно полностью перекрывает находящиеся позади него объекты. К слову, все наши объекты до сих пор являлись полностью не прозрачными. Коэффициент поглощения α определенной среды есть количественная мера, позволяющая оценить, какая доля светового, падающего на поверхность раздела сред, проходит дальше, а какая поглощается. Например, если стекло имеет коэффициент поглощения 0.2, то результирующий цвет будет представлять собой объединение 20% цвета стекла и 80% цвета объектов позади стекла. То есть наблюдатель будет видеть как сам объект, так и предметы позади объекта. В общем случае итоговый цвет полупрозрачного объекта может быть определен по формуле:
c cs cd (1 )
(3.1)
где
c – результирующий цвет
cs – цвет полупрозрачного объекта
cd – цвет фона позади объекта
– коэффициент поглощения, равный 0 для полностью прозрачных объектов, и 1 для непрозрачных.
Перейдем к моделированию эффекта полупрозрачности в XNA Framework. Как вы помните, в XNA Framework любой объект визуализируется с использованием простых примитивов, которые проходят через ряд стадий графического конвейера (см. раздел 2.3.1). В конечном счете, каждый примитив преобразуется в массив пикселей, которые заносятся в кадровый буфер. По сути, итоговый массив пикселей и есть сам объект, а кадровый буфер – его фон. Когда объект является непрозрачным, то пиксели просто копируются в кадровый буфер, затирая старые значения (собственно этим мы до сих пор и занимались). Если же объект является полупрозрачным, то разумно предположить, что приложение должно скомбинировать цвет пикселей объекта с пикселями кадрового буфера по формуле 3.1.
3.4.1. Смешивание цветов. На первый взгляд операцию смешивания пикселей было бы логичным осуществлять в пиксельном шейдере. Но, к сожалению, это не возможно – пиксельный шейдер не может считывать информацию из кадрового буфера, так как реализация данной функциональности ощутимо бы усложнила бы пиксельные процессоры. Соответственно, в современных процессорах эта функциональность реализуется посредством специализированных блоков ROP59, являющихся посредниками между пиксельными процессорами и буфером кадра (рисунок 3.13). Массив пиксельных процессоров
Массив блоков ROP
Буфер кадра
Рисунок 3.13. Принцип работы блоков ROP.
П р им еч а н ие В общем случае количество блоков ROP не обязательно совпадает с числом пиксельных процессоров. Например, графический ускоритель G70 содержит 24 пиксельных процессора и 16 блоков ROP. Подобная асимметрия обусловлена меньшей ресурсоемкостью операций смешения пикселей по сравнению со среднестатистическим пиксельным шейдером, поэтому при равном количестве пиксельных процессоров и ROP часть последних будет в основном простаивать.
Блоки ROP имеют очень ограниченную функциональность и программируются с использованием весьма запутанного синтаксиса. Блок ROP может выполнить над двумя аргументами одну из пяти векторных операций вида:
d op(b s, c d )
(3.2)
где
d – новый цвет пикселя кадрового буфера [d r , d g , db , d a ] op – операция (см. таблица 3.5) s – цвет пикселя
[ sr , sg , sb , sa ]
d – текущий цвет пикселя кадрового буфера
[d r , d g , d b , d a ]
b, с – векторные коэффициенты [br , bg , bb , ba ] и
[cr , cg , cb , ca ]
П р им еч а н ие Наряду с операциями смешения блоки ROP могут выполнять множество других полезных простых операций над пикселями вроде отсечения невидимых областей объектов при визуализации трехмерных цен, реализовывать полноэкранное сглаживание (FSAA – Full Scene Anti Aliasing) и т.д.
Управление смешением пикселей осуществляется посредством свойств свойства RenderState экземпляра класса GraphicsDevice. Активация режима смешения осуществляется путем присвоения значения true свойству AlphaBlendEnable. public bool AlphaBlendEnable { get; set; }
Операция, выполняемая над цветами графического примитива и кадрового буфера задается путем присвоения соответствующего значения перечислимого типа BlendFunction (таблица 3.5) свойству RenderState.BlendFunction. public BlendFunction BlendFunction { get; set; }
Таблица 3.5. Значения перечислимого типа BlendFunction. Значение
Описание
Add (значение по умолчанию)
Покомпонентно складывает два аргумента.
Subtract
Покомпонентно вычитает из первого аргумента (цвет пикселя) второй (цвет кадрового буфера).
ReverseSubtract
Покомпонентно вычитает из второго аргумента (цвет кадрового буфера) первый (цвет пикселя).
59
Raster OPeration Unit – блок операций растеризации.
Max
Покомпонентно сравнивает оба аргумента и возвращает компоненты с наибольшим значением
Min
Покомпонентно сравнивает оба аргумента и возвращает компоненты с наименьшим значением
Каждый из аргументов операции предварительно умножается на коэффициент, определяемый посредством перечислимого типа Blend (таблица 3.6). При этом коэффициент b задается свойством RenderState.SourceBlend, а коэффициент c – свойством RenderState.DestinationBlend: public Blend SourceBlend { get; set; } public Blend DestinationBlend { get; set; }
Таблица 3.6. Некоторые значения перечислимого типа Blend. Значение
Описание
BlendFactor
В качестве коэффициента используется присвоенный свойству RenderState.BlendFactor.
InverseBlendFactor
Коэффициент получается путем вычитания из единичного вектора (1, 1, 1, 1) значения свойства RenderState.BlendFactor.
Zero
Коэффициент равен вектору (0, 0, 0, 0).
One
Коэффициент равен вектору (1, 1, 1, 1).
SourceColor
В качестве коэффициента используются цвета примитива
вектор,
[ sr , sg , sb , sa ] . InverseSourceColor
В
качестве
коэффициента
используется
вектор
[1 sr ,1 sg ,1 sb ,1 sa ] . SourceAlpha
В качестве коэффициента используется вектор альфа каналов примитива
InverseSourceAlpha
В
качестве
[sa , sa , sa , sa ] .
коэффициента
используется
вектор
[1 sa ,1 sa ,1 sa ,1 sa ] . DestinationColor
В качестве коэффициента используется цвет пикселя кадрового буфера
InverseDestinationColor
В
качестве
[d r , d g , d b , d a ] .
коэффициента
используется
вектор
[1 d r ,1 d g ,1 db ,1 d a ] . DestinationAlpha
InverseDestinationAlpha
В качестве коэффициента используется вектор альфаканалов пикселя кадрового буфера
[d a , d a , d a , d a ] .
В
используется
качестве
коэффициента
вектор
[1 d a ,1 d a ,1 d a ,1 d a ] . SourceAlphaSaturation
Вектор
коэффициентов
равен
[ f , f , f ,1] ,
где
f min( sa , d a ) . Хотя первое время от разнообразия параметров рябит в глазах, в действительности все очень просто. Допустим, нам необходимо скомбинировать цвет пикселей примитива с цветом кадрового буфера с использованием следующего экзотического выражения:
d r (1 sr ) sr sa d r d g (1 sg ) sg sa d g
db (1 sb ) sb sa db
(3.3)
Значение альфа канала нас не интересует, так как он все равно не учитывается при выводе изображения на экран. Вдобавок, часто используемые форматы пикселей SurfaceFormat.Bgr565 и SurfaceFormat.Bgr32 не содержат альфа канала. Для начала давайте определим, какой операцией связаны между собой цвет примитива и цвет кадрового буфера. Во всех трех выражениях из произведения с множителями sr , s g , sb вычитается произведение, содержащее множители
d r , d g , db , следовательно для реализации этой формулы необходимо использовать
операцию вычитания BlendFunction.Subtract. Перейдем к коэффициентам. Сопоставив коэффициенты перед
sr , s g , sb с таблицей 3.6 мы придем к
выводу, что они соответствуют значению Blend.InverseSourceColor. Все коэффициенты перед равны
d r , d g , db
sa , следовательно они могут быть заданы с использованием константы Blend.SourceAlpha.
Таким образом, программирование блоков ROP для смешения цветов по формуле 3.3 реализуется посредством следующих четырех строчек кода: device.RenderState.AlphaBlendEnable = true; device.RenderState.BlendFunction = BlendFunction.Subtract; device.RenderState.SourceBlend = Blend.InverseSourceColor device.RenderState.DestinationBlend = Blend.SourceAlpha;
Дополнительная информация Хотя по умолчанию XNA Framework применяет для смешения всех цветовых компонентов общие выражения, он так же позволяет использовать раздельные выражения для смешивания RGB и Alpha компонентов цвета. Эта функциональность активируется посредством присвоения свойству RenderState.SeparateAlphaBlendEnabled значения true, после чего вышеописанные свойства RenderState.BlendFunction, RenderState.SourceBlend и RenderState.DestinationBlend будут влиять исключительно на R, G и B составляющие цвета. Режим смешения альфа-компоненты цвета управляется аналогичной тройкой свойств: RenderState.AlphaBlendOperation, RenderState.AlphaSourceBlend, RenderState.AlphaDestinationBlend. Так как раздельное смешения каналов применяется достаточно редко, мы пока не будет акцентировать на нем внимание.
3.4.2. Использование смешивания цветов для реализации эффекта полупрозрачности. Для реализации визуализации полупрозрачных примитивов, мы должны переложить выражение 3.1 на причудливый язык программирования блоков ROP из XNA Framework. Для простоты мы положим, что весь примитив имеет одну и ту же прозрачность, что позволит нам использовать для задания коэффициента непрозрачности параметр RenderState.BlendFactor. Преимуществом такого подхода является возможность изменения прозрачности всех вершин примитива посредством коррекции одного единственного параметра. Само выражение 3.1 содержит операцию сложения и два коэффициента непрозрачности, так что оно легко реализуется средствами XNA Framework: // Задаем коэффициент непрозрачности (0 – абсолютно прозрачен, 255 – полностью непрозрачен). // При присвоении свойству RenderState.BlendFactor он автоматически приводится к диапазону // 0..1 путем деления на 255. const byte opacity = 50; // Включаем режим смешивания цветов device.RenderState.AlphaBlendEnable = true; // Используем операцию сложения device.RenderState.BlendFunction = BlendFunction.Add; // Коэффициент смешения всех трех компонентов цвета равен opacity (точнее opacity / 255) device.RenderState.BlendFactor = new XnaGraphics.Color(opacity, opacity, opacity, 0); // Коэффициент, на который умножается цвет примитива, равен opacity / 255 device.RenderState.SourceBlend = Blend.BlendFactor; // Коэффициент, на который умножается цвет кадрового буфера, равен 1 - opacity / 255 device.RenderState.DestinationBlend = Blend.InverseBlendFactor;
Для демонстрации использования эффекта полупрозрачности мы строим в решение практического упражнения 3.1 (Examples\Ch03\Ex02) возможность управления прозрачностью круга (рисунок 3.14). Для этого необходимо добавить в группу Параметры ползунок (TrackBar) и две метки, свойства которых перечислены в таблице 3.7.
Рисунок 3.14. Приложение, визуализирующее полупрозрачный круг.
Таблица 3.7. Свойства новых элементов управления группы Параметры. Класс
Свойство
Значение
TrackBar
Name
opacityTrackBar
Minimum
0
Maximum
255
TickFrequency
0
Label
Name
opacityLabel
Label
Text
Непрозрачность:
Так же мне необходимо реализовать обработчик события Scroll ползунка и немного подправить обработчик события Paint (листинг 3.24). Готовый проект приложения находится на CD с книгой в каталоге Examples\Ch03\Ex13. Листинг 3.24. private void opacityTrackBar_Scroll(object sender, EventArgs e) { // Отображаем текущий коэффициент непрозрачности opacityLabel.Text = ((float)opacityTrackBar.Value / 255.0f).ToString("0.00"); // Перерисовывает компонент xnaPanel xnaPanel.Invalidate(); }
private void xnaPanel_Paint(object sender, PaintEventArgs e) { ... // Рисуем шахматную доску (код визуализации шахматной доски взят из пример Ch01\Ex03 // (см. раздел 1.2). ... device.RenderState.CullMode = CullMode.None; device.RenderState.PointSize = 3; device.RenderState.AlphaBlendEnable = true; device.RenderState.BlendFunction = BlendFunction.Add; // Значение параметра BlendFactor вычисляется на основе текущего положения ползунка прокрутки device.RenderState.BlendFactor = new XnaGraphics.Color((byte)opacityTrackBar.Value, (byte)opacityTrackBar.Value, (byte)opacityTrackBar.Value, 0); device.RenderState.SourceBlend = Blend.BlendFactor; device.RenderState.DestinationBlend = Blend.InverseBlendFactor; // Визуализируем круг ... }
3.4.3. Анимация построения фигуры Листажу. В следующем примере мы создадим приложение, анимирующее построение фигуры Листажу, заданной выражением
x sin(2 ) y cos(3 ) где
x , y – координаты текущей точки
– угол, пробегающий с определѐнным шагом значения от 0º до 360º (0…2·π)
Вначале все пиксели фигуры будут прозрачными. Затем мы начнем постепенно увеличивать непрозрачность вершин фигуры, при этом непрозрачность всех вершин будет расти неравномерно: первыми непрозрачными станут вершины, соответствующие углу равному 0, а последними – равному 360º. Соответственно, фигура Листажу будет как бы рисоваться как бы невидимым пером (рисунок 3.15). Так как на этот раз вершины примитива имеют различную прозрачность, задание коэффициента непрозрачности посредством свойства RenderState.BlendFactor вряд ли будет разумным решением. Поэтому мы будем хранить информацию о непрозрачности в альфа-канале цвета вершины, задавая режим смешения пикселей посредством следующего кода: device.RenderState.AlphaBlendEnable = true; device.RenderState.BlendFunction = BlendFunction.Add; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.InverseSourceAlpha;
Чтобы облегчить задачу мы воспользуемся в качестве отправной точки решением практического упражнения 2.5. Все что от нас требуется – переписать обработчик события Idle и немного подправить обработчики событий Load и Paint формы (листинг 3.25).
Рисунок 3.15. Построение фигуры Листажу.
Листинг 3.25. // Пример Examples\Ch03\Ex14 public partial class MainForm : Form { // Количество сегментов в кривой const int QuadStrips = 200; // Количество треугольников в кривой const int TriStrips = QuadStrips * 2; ... // Массив вершин фигуры Листажу VertexPositionColor[] vertices = null; // Таймер Stopwatch stopwatch = null; ... private void MainForm_Load(object sender, EventArgs e) { ... // Создаем массив вершин объекта vertices = new VertexPositionColor[TriStrips + 2]; // Перебираем все вершины фигуры Листажу for (int i = 0; i <= QuadStrips; i++) { // Вычисляем текущий угол float angle = 2.0f * (float)Math.PI * (float)i / (float)QuadStrips; // Рассчитываем координаты текущей точки фигуры листажу float cos = (float)Math.Cos(angle); float x = 0.85f * (float)Math.Sin(2 * angle);
float y = 0.85f * (float)Math.Cos(3 * angle); // Вычисляем цвет точки byte green = (byte)(Math.Pow(0.5f + cos * 0.5f, 0.3f) * 255.0f); byte red = (byte)(Math.Pow(0.5f - cos * 0.5f, 0.3f) * 255.0f); /// Рассчитываем вектор, перпендикулярный графику фигуры листажу длиной 0.015 float sx = -2.55f * (float)Math.Sin(3 * angle); float sy = -1.7f * (float)Math.Cos(2 * angle); float length = (float)Math.Sqrt(sx * sx + sy * sy); float nx = sx / length * 0.015f; float ny = sy / length * 0.015f; // Заносим в массив информацию о двух вершинах фигуры Листажу, смещенных на 0.015 в // направление, перпендикулярном фигуре. Таким образом, кривая фигуры Листажу будет иметь // ширину 0.03 единицы. vertices[i * 2] = new VertexPositionColor(new Vector3(x - nx, y - ny, 0.0f), new XnaGraphics.Color(red, green, 0)); vertices[i * 2 + 1] = new VertexPositionColor(new Vector3(x + nx, y + ny, 0.0f), new XnaGraphics.Color(red, green, 0)); }; ... // Запускаем таймер stopwatch = new Stopwatch(); stopwatch.Start(); // Задаем обработчик события Idle, выполняющий коррекцию прозрачности вершин Application.Idle+=new EventHandler(Application_Idle); // Рассчитываем текущие коэффициенты непрозрачности вершин Application_Idle(this, null); } // Рассчитывает текущие коэффициенты непрозрачности вершин и перерисовывает изображение void Application_Idle(object sender, EventArgs e) { if (closing) Close(); // Получаем текущее время float currentTime = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency; // Перебираем все вершины фигуры листажу for (int i = 0; i <= QuadStrips; i++) { // Вычисляем коэффициент непрозрачности для текущей пары вершин byte opacity = (byte)Math.Max(Math.Min((currentTime - 15.0f * (float)i / (float)QuadStrips) * 255.0f, 255f), 0.0f); // Изменяем коэффициент непрозрачности вершин. К сожалению, структура Color не позволяет // изменять отдельные компоненты цвета, поэтому приходится создавать новую структуру и // указывать значения всех цветовых компонентов. vertices[i * 2].Color = new XnaGraphics.Color(vertices[i * 2].Color.R, vertices[i * 2].Color.G, vertices[i * 2].Color.B, opacity); vertices[i * 2 + 1].Color = new XnaGraphics.Color(vertices[i * 2 + 1].Color.R, vertices[i * 2 + 1].Color.G, vertices[i * 2 + 1].Color.B, opacity); }; // Если фигура Листажу полностью визуализирована (она визуализируется ровно 16 секунд) if (currentTime > 16.0f) {
// Прекращаем анимацию, дабы не загружать центральный процессор и видеокарту бесполезной // работой. Application.Idle -= new EventHandler(Application_Idle); // Выключаем таймер stopwatch.Stop(); } // Перерисовываем форму Invalidate(); } private void MainForm_Paint(object sender, PaintEventArgs e) { ... device.RenderState.CullMode = CullMode.None; device.RenderState.FillMode = fillMode; // Задаем режим смешения пикселей device.RenderState.AlphaBlendEnable = true; device.RenderState.BlendFunction = BlendFunction.Add; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.InverseSourceAlpha; device.VertexDeclaration = decl; // Визуализируем фигуру Листажу 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(); } }
Заключение В этой главе мы научились осуществлять визуализацию как на поверхность компонентов Windows Forms, так и на целый экран с выбором требуемого видеорежима, и даже на поверхность элементов Win32. Так же мы научились реализовать плавную анимацию и моделировать полупрозрачность посредством смешивания с учѐтом альфа канала цвета.
Глава 4. Хранитель экрана В этой главе мы закрепим изученный ранее материал, создав с использованием XNA полнофункциональный хранитель экрана (ScreenSaver), который отображает на экране фейерверк: вращающийся круг, из которого вылетает множество разноцветных искр (рисунок 4.1). Что такое хранитель экрана? Это приложение, автоматически запускаемое Windows по истечению определенного строка бездействия компьютера. Изначально использование хранителя экрана было обусловлено сугубо практическими соображения: первые мониторы обладали весьма ограниченным ресурсом, в результате чего длительное отображение статичного изображения приводило к выгоранию люминофора в определенных местах и, соответственно, “запоминанию” монитором этого изображения60. Современные мониторы обладают значительно более продолжительным сроком службы, поэтому в настоящее время хранитель экрана скорее выполняет декоративную роль.
Рисунок 4.1. Хранитель экрана.
Технически хранитель экрана представляет собой исполняемый exe-файл с расширением .scr. Запуская хранитель экрана, Windows предает ему один из трех параметров командной строки, перечисленных в таблице 4.1. Разработчики C++ обычно создают хранители экрана с использованием библиотеки Scrnsave.lib (или Scrnsavw.lib), которые самостоятельно обрабатывают параметры командной строки и реализуют завершение работы хранителя экрана при перемещении мыши, нажатии клавиши и т.п. Фактически разработчику необходимо лишь переопределить обработчик события WM_PAINT и реализовать код диалогового окна. П р им еч а н ие К справедливости следует отметить, что реализация диалогового окна средствами Win32 API является далеко не самой приятной задачей.
60
Например, длительное отображение панелей Norton Commander приводило к выжиганию в люминофоре монитора “изображения” данных панелей.
Таблица 4.1. Параметры командой строки хранителя экрана
Параметр командной строки
Описание
/s
Запускает хранитель экрана в обычном режиме.
/c{:n}
Показывает диалоговое окно конфигурации хранителя экрана. n – дескриптор родительского диалогового окна Display Properties.
/p {n}
Запускает хранитель экрана в окне предварительного просмотра с дескриптором n.
Нет параметров
Показывает диалоговое хранителя экрана.
окно
конфигурации
В настоящее время .NET Framework 2.0 не содержит аналога библиотеки Scrnsave.lib, поэтому нам придется реализовывать всю функциональность самим. Это далеко не такая тривиальная задача, как хотелось бы, но и отнюдь не архисложная. Так что в путь! П р им еч а н ие В состав Visual Studio 2005 Pro входит Screen Saver Starter Kit, позволяющий создать проект готового хранителя экрана буквально одним щелчком мыши. Но, к сожалению, Screen Saver Starter Kit ряд недоделок61 и даже ошибок, поэтому мы не будем его использовать.
4.1. Реализация вращающегося диска. Учитывая сложность стоящей перед нами задачи, мы разобьем еѐ на несколько этапов. Начнем мы с визуализации вращающегося цветного диска. Создайте новый проект Windows Forms, установив в диалоговом окне New Project флажок Create directory for solution (в последствие мы добавим в решение проект инсталлятора). Нам придется выполнять визуализацию как в полноэкранном режиме, так и в окне предварительного просмотра, поэтому весь код визуализации будет логично разместить в отдельном классе. Добавьте в проект новый класс Firework (Project | Add Class). Внутри класса Firework могут возникать критические исключения (например, при инициализации графического устройства). Обрабатывать в коде, использующем класс Firework, разношерстные исключения вроде ошибки создания графического устройства дольно утомительно, поэтому класс Firework будет перехватывать низкоуровневые исключения и генерировать своѐ собственное исключение FireworkException с ясным описанием причины возникновения ошибки. Листинг 4.1. class FireworkException : Exception { public FireworkException(string message) : base(message) { } }
Для визуализации круга классу Firework необходимо загрузить и скомпилировать эффект. Только вот где его хранить? Типовой хранитель экрана обычно состоит из одного файла с расширением .scr, поэтому использование дополнительного fx-файла является далеко не самой лучшей идеей. К счастью Visual Studio позволяет легко внедрить fx-файл непосредственно в exe-файл. Для этого включите в проект файл ColorFill.fx, используемый во всех примерах этой главы, и присвойте свойству Build Action этого файла значение Embedded Resource (рисунок 4.2).
61
Например, не реализована возможность отображения хранителя экрана в окне предварительного просмотра.
Рисунок 4.2. Внедрение fx-файла в сборку.
Основные фрагменты кода класса Firework приведены в листинге 4.2. Листинг 4.2. // Полный текст примера находится на CD книги в каталоге Examples\Ch04\Ex01 class Firework : IDisposable { // Место расположение ресурса с кодом файла эффекта const string effectFileName = "GSP.XNA.Book.Ch04.Ex01.Data.ColorFill.fx"; // Количество сегментов в диске const int slices = 64; // Угловая скорость вращения диска (радиан в секунду) public const float diskSpeed = 3.0f; // Радиус диска public const float diskRadius = 0.015f; // Текущий угол поворота float diskAngle = 0; // Дескриптор окна, на которое будет осуществляться визуализация ... // // // //
Конструктор. Принимает: hWnd – дескриптор окна в котором осуществляется визуализация. scintillaSize – размер искр (пока игнорируется) scintillaInterval – интервал между искрами (пока игнорируется) public Firework(IntPtr hWnd, float scintillaSize, float scintillaInterval) { // Сохраняем дескриптор окна this.hWnd = hWnd; presentParams = new PresentationParameters(); presentParams.BackBufferCount = 1;
presentParams.SwapEffect = SwapEffect.Discard; presentParams.PresentationInterval = PresentInterval.One; try { 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, hWnd, options, presentParams); } // Это исключение обычно генерируется при отключенном аппаратном ускорении в Display // Properties catch (DeviceNotSupportedException) { // Перехватываем исключение и генерируем собственное исключение с более понятным описанием // проблемы throw new FireworkException("Не могу создать устройство Direct3D"); }
decl = new VertexDeclaration(device, VertexPositionColor.VertexElements); diskVertices = new VertexPositionColor[slices + 2]; // Загружаем эффект из ресурсов сборки Stream effectStream = Assembly.GetExecutingAssembly().GetManifestResourceStream( effectFileName); // Выполняем компиляцию эффекта CompiledEffect compiledEffect = Effect.CompileEffectFromFile(effectStream, null, null, CompilerOptions.None, TargetPlatform.Windows); if (!compiledEffect.Success) throw new FireworkException(String.Format( "Ошибка при компиляции эффекта: \r\n{0}", compiledEffect.ErrorsAndWarnings)); effect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); if (!effect.CurrentTechnique.Validate()) throw new FireworkException(String.Format("Ошибка при валидации техники \"{0}\""+ “эффекта \"{1}\"\n\rСкорее всего, функциональность шейдера превышает возможности GPU", effect.CurrentTechnique.Name, effectFileName)); stopwatch = new Stopwatch(); stopwatch.Start(); } // Рассчитывает угол, на который должен повернуться диск с момента последнего вызова этого // метола и выполняет собственно поворот. public void Update() { // Так как хранитель экрана может работать часами, значение переменной currentTime может // достигнуть достаточно большой величины. Поэтому, во избежание падения точности вычислений // используется тип double
double currentTime = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency; // Переменная delta принимает очень ограниченный диапазон значений, поэтому здесь вполне // можно обойтись типом float 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 <= slices; i++) { float angle = (float)i / (float)slices * 2.0f * (float)Math.PI; float x = diskRadius * (float)Math.Sin(diskAngle + angle); float y = diskRadius * (float)Math.Cos(diskAngle + angle); byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2))); diskVertices[i + 1] = new VertexPositionColor(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red, green, 128)); }; lastTime = currentTime; } // Выполняет визуализацию изображения public void Paint() { ... } // Освобождает ресурсы формы public void Dispose() { if (stopwatch != null) stopwatch.Stop(); if (device != null) { device.Dispose(); device = null; } } }
Теперь, давайте, интегрируем функциональность класса Firework в главную форму приложения (листинг 4.3). Листинг 4.3. public partial class FullscreenForm : Form { Firework firework = null; public FullscreenForm() { InitializeComponent(); }
private void FullscreenForm_Load(object sender, EventArgs e) { SetStyle(ControlStyles.Opaque | ControlStyles.ResizeRedraw, true); MinimumSize = SizeFromClientSize(new Size(1, 1)); try { firework = new Firework(Handle, 0.0f, 0.0f); } catch (FireworkException ex) { MessageBox.Show(ex.Message, "Критическая ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error); } Application.Idle += new EventHandler(Application_Idle); } void Application_Idle(object sender, EventArgs e) { if (firework == null) { Close(); return; } firework.Update(); Invalidate(); } private void FullscreenForm_FormClosed(object sender, FormClosedEventArgs e) { if (firework != null) { firework.Dispose(); firework = null; } } private void FullscreenForm_Paint(object sender, PaintEventArgs e) { if (firework == null) return; firework.Paint(); } }
В заключение необходимо развернуть форму на весь экран и убрать заголовок. Для этого присвойте свойству FormBorderStyle значение None, а свойству WindowState значение Maximized. После запуска полученного приложения экран окрасится в черный цвет, а в центре появится маленький вращающийся пестрый диск.
4.2. Фейерверк искр. На следующем этапе мы добавим в наше приложение снопы искр, вылетающие из крутящегося диска. Траектория движения искры, напоминающая спираль, будет складываться как композиция движения искры из центра диска по прямой с постепенным замедлением и движения по окружности вокруг диска с постоянно уменьшающейся угловой скоростью.
Начальный цвет искры будет совпадать с цветом диска в окрестностях появившейся искры. С течением времени искра постепенно затухает посредством уменьшения коэффициента непрозрачности, пока он не достигнет нуля, после чего искра считается потухшей. Всю эту функциональность разумно инкапсулировать в отдельном классе, вложенном в класс Firework (листинг 4.4). Листинг 4.4. class Scintilla { // Время жизни частицы const float StartTime = 6.0f; // Диапазон скоростей прямолинейного движения частицы const float MinSpeed = 0.3f; const float MaxSpeed = 0.45f; // Коэффициент замедления прямолинейного движения частицы const float tSlowing = 0.105f; // Коэффициент замедления угловой скорости вращательного движения частицы const float rSlowing = 0.25f; // Цвет частицы public Color color; // Текущие угол поворота частицы вокруг круга и расстояние от центра круга (т.е. координаты // частицы задаются в сферической системе координат) public float angle; public float distance; // Оставшееся время жизни частицы public float time; // Текущая скорость прямолинейного движения public float tSpeed; // Текущая скорость вращательного движения public float rSpeed; // Возвращает структуру с информацией о частице public VertexPositionColor Vertex { get { VertexPositionColor vertex; // Рассчитываем декартовые координаты частицы vertex.Position.X = distance * (float)Math.Sin(angle); vertex.Position.Y = distance * (float)Math.Cos(angle); vertex.Position.Z = 0; // Вычисляем новый коэффициент непрозрачности частицы vertex.Color = new Color(color.R, color.G, color.B, (byte)(255f * time / StartTime)); return vertex; } } // Рассчитывает параметры новой частицы. diskAngle – угол поворота диска, используемый для // синхронизации цвета частицы с цветом диска в окрестностях вершины public void Init(float diskAngle) { // Вычисляем начальное расстояние вершины от центра диска (вершина должна находиться в центре // диска). Генератор случайных чисел rnd объявлен в классе Firework distance = (float)rnd.NextDouble() * Firework.diskRadius; // Вычисляем начальный угол поворота искры вокруг диска angle = (float)Firework.rnd.NextDouble() * 2.0f * (float)Math.PI;
// Определяем начальную скорость прямолинейного движения искры tSpeed = MinSpeed + (float)Firework.rnd.NextDouble() * (MaxSpeed - MinSpeed); // Угловая скорость движения вершины всегда в 4 раза меньше скорости диска rSpeed = Firework.diskSpeed / 4.0f; // Рассчитываем начальный цвет искры (он должен совпадать с цветом диска) byte red = (byte)(255 * Math.Abs(Math.Sin((angle - diskAngle) * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos((angle - diskAngle) * 2))); color = new Color(red, green, 128, 255); // Задаем оставшееся время жизни искры time = StartTime; } // Обновляет состояние искры. delta – время, прошедшее с момента последнего вызова метода // Update public void Update(float delta) { // Если искра еще не потухла if (time > 0.0f) { // Уменьшаем оставшееся время жизни искры time = time - delta; // Корректируем скорости прямолинейного и вращательного движений tSpeed = Math.Max(tSpeed - tSlowing * delta, 0.0f); rSpeed = Math.Max(rSpeed - rSlowing * delta, 0.0f); // Корректируем положение искры в пространстве distance += tSpeed * delta; angle += rSpeed * delta; } } }
Пользователь сможет управлять видом фейерверка искр посредством двух параметров: Размер искр. Максимальное количество искр, появляющихся каждые 5 миллисекунд (дискретный шаг, с которым выполняется моделирование логики работы хранителя экрана). Так как количество искр на экране постоянно меняется, встает вопрос хранения информации об искрах. В принципе, для этой цели вполне подходит обобщенный класс List за исключением пары нюансов: 45. Удаление элементов из середины списка является очень дорогой операцией. 46. Метод DrawUserPrimitives может визуализировать вершины исключительно из массивов. Однако преобразование списка List в массив посредством метода ToArray() сопряжено с выделением памяти, что в свою очередь может привести к частым вызовам сборщика мусора и, соответственно, провалам производительности. Первый недостаток мы обойдем достаточно хитро. Потухшие искры не будут удаляться из списка – вместо этого информация о новых искрах будет просто заноситься в элементы списка с потухшими искрами, затирая их, и лишь при отсутствии таковых добавляться в конец списка. Для борьбы со вторым недостатком мы будем самостоятельно копировать содержимое списка в массив, при этом выделение памяти для массива будет производиться лишь при недостаточном размере целевого массива. В листинге 4.5 приведены фрагменты обновленного класса Firework с учетом вышеприведенных требований. Листинг 4.5. class Firework : IDisposable { ... // Дискретный шаг времени, с которым выполняется моделирование логики работы хранителя экрана const float timeStep = 0.005f;
// Максимальное время между вызовами Update не должно превышать 1 секунды. Большие временные // интервалы игнорируются const float maxDelta = 1.0f; // Максимальное количество искр, которое может появиться за один дискретный шаг времени (5 // миллисекунд) int maxScintillaCount; // Вероятность появления следующей искры const float scintillaProbability = 0.3f; // Размер искры float scintillaSize; ... // Время, прошедшее с момента запуска приложения double lastTime = 0; // Список искр List<Scintilla> 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 <= slices; i++) { float angle = (float)i / (float)slices * 2.0f * (float)Math.PI; float x = diskRadius * (float)Math.Sin(diskAngle + angle); float y = diskRadius * (float)Math.Cos(diskAngle + angle); byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2))); diskVertices[i + 1] = new VertexPositionColor(new Vector3(x, y, 0.0f), new XnaGraphics.Color(red, green, 128)); };
Давайте внимательно рассмотрим этот код и прикинем, как его перенести в вершинный шейдер. Логично предположить, что код вне цикла не стоит выносить в вершинный шейдер, а вот собственно код цикла, выполняемый для каждой вершины диска, напротив, является идеальным кандидатом для переноса в вершинный шейдер. Но при этом следует учесть несколько нюансов: Результат вычисления переменной angle для конкретной вершины всегда является константой. Поэтому значение переменной angle разумнее всего один раз рассчитать для каждой вершины и затем передавать в шейдер в качестве параметра. Цвет вершины является постоянной величиной, поэтому его тоже лучше один раз рассчитать заранее и передавать в вершинный шейдер в качестве параметра. Так как центральная вершина диска всегда остается неизменной, она рассчитывается независимо от остальных вершин. Но язык Vertex Shader 1.1 не поддерживает ветвления, поэтому нам придется рассчитывать параметры центральной вершины наравне с остальными, считая что она удалена от центра диска на нуль единиц. На следующем этапе мы должны определиться с информацией, передаваемой в вершинный шейдер и составить небольшую табличку наподобие таблицы 5.4. Информацию общую для всех вершин логично предавать через параметры шейдера, отображаемые на константные регистры. А вот информацию об удалении вершины от начала координат и угле еѐ локального поворота мы будем передавать через координаты вершины. Возможно, это вам покажется очень странным, но нечего противоестественного в этом нет – в разделе 5.3.1 говорилось, что атрибуты вершинны (координаты, цвет и т.п.) просто отображаются на входные регистры виртуального процессора v0, v1 … v15, а уж как трактовать информацию, хранимую в этих регистрах – это уже дело исключительно вершинного шейдера. Таблица 5.4. Входные параметры вершинного шейдера, визуализирующего диск
Описание параметра
Аналогичная переменная из листинга 5.8
Общий для всех вершин
Место хранения
Угол поворота всех вершин, меняющийся с течением времени
diskAngle
Да
Входной параметр angle
Расстояние текущей вершины от центра диска
diskRadius
Нет
Координата вершины X
Локальный угол поворота текущей вершины
Angle
Нет
Координата вершины Y
Цвет текущей вершины
red/green
Нет
Цвет вершины
Прототип вершинного шейдера В принципе, теперь можно приступать к написанию вершинного шейдера, но мы с этим делом немного повременим. Дело в том, что вершинные шейдеры достаточно капризны и трудоемки в плане отладки, а подобные сложные шейдеры мы ещѐ никогда не писали. Поэтому для начала мы создадим на C# класс DiskEffect, эмулирующий функциональность нашего будущего вершинного шейдера (листинг 5.9). Это позволит нам, если что-то пойдет не так, легко поставить точку останова в коде шейдера и проверить корректность входных параметров или выполнить трассировку “шейдера” по шагам с просмотром состояния переменных81. Листинг 5.9. // Эмулятор эффекта вращения диска static class DiskEffect { // Параметр эффекта public static float angle; // Вершинный шейдер // input – входная информация о вершине // output – выходная информация о вершине public static void VertexShader(VertexPositionColor[] input, VertexPositionColor[] output) { // Перебираем все вершины (в коде реального вершинного шейдера цикла не будет, ведь он // автоматически будет вызываться для каждой вершины for (int i = 0; i < input.Length; i++) { // Вычисляем итоговый угол поворота вершины. Информация об углах поворота вершины берется из // параметра angle и координаты Y float a = input[i].Position.Y + angle; // Вычисляем координаты вершины. Расстояние вершины от центра диска берется из координаты X output[i].Position.X = input[i].Position.X * (float)Math.Sin(a); output[i].Position.Y = input[i].Position.X * (float)Math.Cos(a); output[i].Position.Z = 0; // Цвет вершины проходит через вершинный шейдер без изменений output[i].Color = input[i].Color; } } }
Разумеется, применение подобного вершинного шейдера приведет к значительным изменениям в коде примера Ch04\Ex01 (прототипа хранителя экрана из четвертой главы). Наиболее значимые фрагменты кода нового варианта приложения с подробными комментариями приведены в листинге 5.10.
81
В DirectX SDK имеется утилита PIX for Windows, позволяющая выполнять трассировку ассемблерного кода шейдера. Использование данной утилиты будет рассмотрено в следующей главе.
Листинг 5.10. public partial class MainForm : Form { // Обычный эффект для визуализации объектов. Пропускает через себя информацию о вершинах без // изменений. Вращение диска осуществляется посредством класса-эмулятора вершинного шейдера const string effectFileName = "Data\\ColorFill.fx"; // Число сегментов в диске const int slices = 64; // Скорость вращения диска public const float diskSpeed = 3.0f; // Радиус диска public const float diskRadius = 0.018f; GraphicsDevice device; PresentationParameters presentParams; VertexDeclaration diskDeclaration; // Массив с информацией о вершинах диска VertexPositionColor[] diskVertices = null; // Массив с информацией о вершинах диска, обработанных вершинным шейдеров. Используется // исключительно для эмуляции работы вершинного шейдера VertexPositionColor[] transformedDiskVertices = null; Effect diskEffect = null; Stopwatch stopwatch; bool closing = false; private void MainForm_Load(object sender, EventArgs e) { ... // Создаем графическое устройство device = new GraphicsDevice(GraphicsAdapter.DefaultAdapter, DeviceType.Hardware, this.Handle, options, presentParams); // Декларация формата вершины diskDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements); // Создаем массив вершин диска diskVertices = new VertexPositionColor[slices + 2]; // Создаем массив вершин диска, обработанных вершинным шейдером (используется при эмуляции // вершинного шейдера) transformedDiskVertices = new VertexPositionColor[slices + 2]; // Заносим в массив вершин информацию о вершинах диска (цвета, углы поворота и расстояния от // центра) diskVertices[0] = new VertexPositionColor(new Vector3(0.0f, 0.0f, 0.0f), XnaGraphics.Color.LightGray); for (int i = 0; i <= slices; i++) { float angle = (float)i / (float)slices * 2.0f * (float)Math.PI; byte red = (byte)(255 * Math.Abs(Math.Sin(angle * 3))); byte green = (byte)(255 * Math.Abs(Math.Cos(angle * 2))); // Заносим в массив информацию о текущей вершине diskVertices[i + 1] = new VertexPositionColor(new Vector3(diskRadius, angle, 0.0f), new XnaGraphics.Color(red, green, 128)); };
... // Создаем эффект для визуализации объекта diskEffect = new Effect(device, compiledEffect.GetEffectCode(), CompilerOptions.NotCloneable, null); // Создаем и запускаем таймер stopwatch = new Stopwatch(); stopwatch.Start(); } private void MainForm_Paint(object sender, PaintEventArgs e) { ... // Вычисляем новый угол поворота диска и присваиваем его “параметру эффекта” float time = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency; DiskEffect.angle = diskSpeed * time; // Выполняем “виртуальный вершинный шейдер” DiskEffect.VertexShader(diskVertices, transformedDiskVertices); // Задаем декларацию формата вершины device.VertexDeclaration = diskDeclaration; // Визуализируем диск diskEffect.Begin(); for (int i = 0; i < diskEffect.CurrentTechnique.Passes.Count; i++) { EffectPass currentPass = diskEffect.CurrentTechnique.Passes[i]; currentPass.Begin(); // Используем трансформированные вершины device.DrawUserPrimitives(PrimitiveType.TriangleFan, transformedDiskVertices, 0, diskVertices.Length - 2); currentPass.End(); } diskEffect.End(); device.Present(); } ... }
Готовое приложение находится на CD-диске с книгой в каталоге Exampes\Ch05\Ex05.
Полноценный эффект Отладив прототип эффекта можно приступать к реализации настоящего полноценного эффекта на языке HLSL. Используя в качестве шпаргалки листинг 5.9, написание вершинного шейдера не составит труда. Всѐ что от нас требуется – убрать цикл перебора вершин и привести синтаксис в соответствии языку HLSL (листинг 5.11). Листинг 5.11. // Файл Disk.fx float angle; struct VertexInput { float3 pos : POSITION; float4 color : COLOR; };
struct VertexOutput { float4 pos : POSITION; float4 color : COLOR; };
VertexOutput MainVS(VertexInput input) { VertexOutput output; float a = input.pos.y + angle; output.pos.xy = input.pos.xx * float2(sin(a), cos(a)); output.pos.zw = float2(0.0, 1.0); output.color = input.color; return output; } float4 MainPS(float4 color:COLOR):COLOR { return color; } technique Disk { pass p0 { VertexShader = compile vs_1_1 MainVS(); PixelShader = compile ps_1_1 MainPS(); } }
Преобразование приложения тоже выполняется тривиально и сводится к удалению вспомогательного кода, эмулирующего вершинный шейдер: необходимо убрать ставшие ненужными класс DiskEffect и массив вершин, обработанных вершинным шейдером (transformedDiskVertices). А угол поворота теперь должен присваиваться непосредственно параметру эффекта angle. Основные фрагменты обновленного приложения приведены в листинге 5.12. Листинг 5.12. public partial class MainForm : Form { // Используем новый эффект const string effectFileName = "Data\\Disk.fx"; Effect diskEffect = null; // Объект EffectParameter, инкапсулирующий параметр эффекта angle EffectParameter angleParam = null; private void MainForm_Load(object sender, EventArgs e) { ... // Получаем объект EffectParameter, соответствующий параметру эффекта angle angleParam = diskEffect.Parameters["angle"]; Debug.Assert(angleParam != null, effectFileName + " : не найден параметр angle"); } private void MainForm_Paint(object sender, PaintEventArgs e)
{ ... float time = (float)stopwatch.ElapsedTicks / (float)Stopwatch.Frequency; // Присваиваем угол поворота параметру angle эффекта angleParam.SetValue(diskSpeed * time); // Выполняем обычную визуализацию примитива device.VertexDeclaration = diskDeclaration; diskEffect.Begin(); for (int i = 0; i < diskEffect.CurrentTechnique.Passes.Count; i++) { EffectPass currentPass = diskEffect.CurrentTechnique.Passes[i]; currentPass.Begin(); device.DrawUserPrimitives(PrimitiveType.TriangleFan, diskVertices, 0, diskVertices.Length - 2); currentPass.End(); } diskEffect.End(); device.Present(); ... } }
Как видно, обработчик события Paint лишь вычисляет новый угол поворота вершины и передает его в параметр эффекта шейдера. Собственно вращение вершин диска осуществляется только силами вершинного шейдера без какой-либо помощи со стороны C#-кода.
Анализ ассемблерного кода вершинного шейдера Закончив создание эффекта самое время ознакомиться с ассемблерным кодом, сгенерированным компилятором82: // // // // // // // // // // // // // // // // // // // //
Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 Parameters: float angle;
Registers: Name Reg Size ------------ ----- ---angle c0 1
Default values: angle c0
= { 0, 0, 0, 0 };
vs_1_1 def c1, 0.159154937, 0.25, 0.5, -0.00138883968 def c2, 6.28318548, -3.14159274, -2.52398507e-007, 2.47609005e-005
82
Чтобы облегчить чтение листинга, я пронумеровал ассемблерные инструкции.
1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13.
def c3, 0.0416666418, -0.5, 1, 0 dcl_position v0 dcl_color v1 add r0.w, v0.y, c0.x mad r1.xy, r0.w, c1.x, c1.yzzw frc r0.xy, r1 mad r0.xy, r0, c2.x, c2.y mul r0.xy, r0, r0 mad r1.xy, r0, c2.z, c2.w mad r1.xy, r0, r1, c1.w mad r1.xy, r0, r1, c3.x mad r1.xy, r0, r1, c3.y mad r0.xy, r0, r1, c3.z mul oPos.xy, r0, v0.x mov oPos.zw, c3.xywz mov oD0, v1
// approximately 15 instruction slots used
И что же мы видим? Параметру angle отведен входной регистр c0, но вот регистры c1, c2 и c3 почему-то содержат множество непонятных констант, а компактный код вершинного шейдера превратился в 13 ассемблерных инструкций, которые в действительности транслируются в 15 команд виртуального вершинного процессора. Несовпадение числа инструкций и команд обусловлено макро-инструкцией frc, разворачиваемой в три команды вершинного процессора. Окинуть одним взглядом структуру данной программы весьма проблематично, поэтому нам придется последовательно проследить выполнение программы по шагам, и попытаться понять, что же они выполняет каждая еѐ команда. П р им еч а н ие Данный пример наглядно демонтирует, что короткий код вершинного шейдера вовсе не означает малый размер ассемблерной программы, и что ограничение максимальной длины программы Vertex Shader 1.1 в 128 инструкций не так уж и много.
Смысл первой команды весьма очевиден – она вычисляет сумму a = input.pos.y + angle и заносит результат в компонент w временного регистра r0. Следующая команда умножает и складывает полученное значение переменной с “чудными” константами, но еѐ смысл нам пока не ясен. Логично предположить, что эти действия имеют какое-то отношение к вычислению значения тригометрических функций sin и cos (виртуальный процессор Vertex Shader 1.1 не имеет инструкций для расчета синуса и косинуса). Что ж, давайте просто запишем это выражение как есть, заменив компоненты константных регистров их численными значениями: r1.x = a 0.159154937 + 0.25 r1.y = a 0.159154937 + 0.5
Присмотревшись внимательно к константе 0.159154937 мы обнаружим, что это есть нечто иное, как единица деленная на удвоенное число «пи»: 𝑎 𝑟1. 𝑥 = + 0.25 2∙𝜋 𝑎 𝑟1. 𝑦 = + 0.5 2∙𝜋 Следующая команда вычисляет дробную часть выражения и заносит еѐ в регистр r0: 𝑎 𝑟0. 𝑥 = + 0.25 2∙𝜋 𝑎 𝑟0. 𝑦 = + 0.5 2∙𝜋 После обработки четвертой команды содержимое регистра r0 преобразится следующим образом: 𝑎 𝑟0. 𝑥 = + 0.25 ∙ 6.28318548 − 3.14159274 2∙𝜋 𝑎 𝑟0. 𝑦 = + 0.5 ∙ 6.28318548 − 3.14159274 2∙𝜋 Нетрудно догадаться, что 3.14159274 – это число «пи», а 6.28318548 – число «пи» умноженное на два:
𝑎 + 0.25 − 𝜋 2∙𝜋 𝑎 𝑟0. 𝑦 = 2 ∙ 𝜋 ∙ + 0.5 − 𝜋 2∙𝜋 На первый взгляд эти формулы могут показаться сущей несуразицей, но поэкспериментировав со значениями r0.y в MathCad можно обнаружить, что они обладает двумя важными свойствами 83: 𝑟0. 𝑥 = 2 ∙ 𝜋 ∙
каким бы не было значение a, r0.y всегда находится в диапазоне [-π, +π) cos(a) = cos(r0.y) Следовательно, после выполнения второй, третьей и четвертой команд угол a преобразуется к диапазону [-π, +π), при этом косинус угла остается неизменным. Зачем это надо? Логично предположить, что значение косинуса будет вычисляться путем разложения в ряд Тейлора, точность которого стремительно падает с ростом модуля аргумента. Так что данное преобразование позволяет значительно повысить точность вычисления функции cos(a) для больших аргументов. Кстати, если бы не это преобразование, то по мере вращения круга точность вычислений косинуса стремительно снижалась и, в конце концов, круг перестал бы корректно вращаться. C компонентом x регистра r0 всѐ несколько запутаннее: Значение r0.x находится в диапазоне [-π, +π) sin(a) = cos(r0.x) 𝜋
Очевидно, команды 2-4 используют известное тригонометрическое тождество sin 𝑥 = cos (𝑥 − ) . Не 2 заглядывая вперед трудно наверняка сказать, зачем компилятор HLSL выполняет данное преобразования, но с большой долей вероятности можно предположить, что синус будет вычисляться через косинус. Что ж, давайте введем условные обозначения для углов, преобразованных к диапазону, [-π, +π) и продолжим анализ кода: 𝑎 𝑎𝑥 = 𝑟0. 𝑥 = 2 ∙ 𝜋 ∙ + 0.25 − 𝜋 2∙𝜋 𝑎 𝑎𝑦 = 𝑟0. 𝑦 = 2 ∙ 𝜋 ∙ + 0.5 − 𝜋 2∙𝜋 Пятая команда возводит регистр r0 в квадрат, а шестая умножает и складывает его с константами и заносит результат в регистр r1: 𝑟1. 𝑥 = −2.52398507 ∙ 10−7 ∙ 𝑎𝑥 2 + 2.47609005 ∙ 10−5 𝑟1. 𝑦 = −2.52398507 ∙ 10−7 ∙ 𝑎𝑦 2 + 2.47609005 ∙ 10−5 Шестая команда умножает регистр r1 на ax2 и складывает с константой -0.00138883968: 𝑟1. 𝑥 = −2.52398507 ∙ 10−7 ∙ 𝑎𝑥 2 + 2.47609005 ∙ 10−5 ∙ 𝑎𝑥 2 − 0.00138883968 𝑟1. 𝑦 = −2.52398507 ∙ 10−7 ∙ 𝑎𝑦 2 + 2.47609005 ∙ 10−5 ∙ 𝑎𝑦 2 − 0.00138883968 Следующие три команды продолжают выполнение серии последовательных умножений на ax2 и сложений с константами. В результате после выполнения десятой команды в регистре r0 оказывается следующие значение84: 𝑟0. 𝑥 =
−2.523 ∙ 10−7 ∙ 𝑎𝑥 2 + 2.476 ∙ 10−5 ∙ 𝑎𝑥 2 − 0.001388 ∙ 𝑎𝑥 2 + 0.0416 ∙ 𝑎𝑥 2 − 0.5 ∙ 𝑎𝑥 2 + 1
𝑟0. 𝑦 =
−2.523 ∙ 10−7 ∙ 𝑎𝑦 2 + 2.476 ∙ 10−5 ∙ 𝑎𝑦 2 − 0.001388 ∙ 𝑎𝑦 2 + 0.0416 ∙ 𝑎𝑦 2 − 0.5 ∙ 𝑎𝑦 2 + 1
Чтобы понять смысл данного выражения раскроем скобки и упорядочим коэффициенты ax и ay по убыванию степени: 𝑟0. 𝑥 = 1 − 0.5 ∙ 𝑎𝑥 2 + 0.0416 ∙ 𝑎𝑥 4 − 0.001388 ∙ 𝑎𝑥 6 + 2.476 ∙ 10−5 ∙ 𝑎𝑥 8 − 2.523 ∙ 10−7 ∙ 𝑎𝑥10 𝑟0. 𝑦 = 1 − 0.5 ∙ 𝑎𝑦 2 + 0.0416 ∙ 𝑎𝑦 4 − 0.001388 ∙ 𝑎𝑦 6 + 2.476 ∙ 10−5 ∙ 𝑎𝑦 8 − 2.523 ∙ 10−7 ∙ 𝑎𝑦10 Ничего не напоминает? Правильно, это разложение функции cos(x) в ряд Тейлора до члена десятой степени: 𝑥 2 𝑥 4 𝑥 6 𝑥 8 𝑥 10 𝑥 2𝑛 + − + − + ⋯ + (−1)𝑛 ∙ 2! 4! 6! 8! 10! 2∙𝑛 ! Таким образом, ассемблерные команды с пятой по десятую вычисляют косинус угла. Соответственно, после выполнения десятой команды в компоненте y регистра r0 находится косинус угла, а в компоненте x – синус угла (как вы помните sin(a) = cos(ax), cos(a) = cos(ay)). При этом векторные регистры cos 𝑥 = 1 −
83 84
Они достаточно легко доказываются математически Чтобы сделать выражение удобочитаемым, число знаков в коэффициентах было уменьшено.
вершинного процессора позволили компилятору HLSL параллельно рассчитать значения тригонометрических функций.
обоих
Точность вычисления cos(x) Приблизительную оценку аппроксимации функции cos рядом Тейлора из пяти членов можно легко выполнить в том же MathCad, построив график модуля разницы между суммой пяти членов ряда Тейлора и встроенной функцией cos (рисунок 5.19). Как видно, по мере приближения модуля угла к π абсолютная погрешность стремительно растет, достигая при угле равном π величины порядка ±210-3. Если такая точность недостаточна для ваших задач, можно попробовать уменьшить модуль значения аргумента cos, воспользовавшись тождеством cos(x)=-cos(x±π).
Рисунок 5.19. Оценка абсолютной погрешности вычисления косинуса посредством ряда Тейлора в Mathcad.
Остальной код весьма тривиален. Одиннадцатая команда умножает полученные значения синуса и косинуса на расстояние вершины до центра и записывает результат в компоненты x и y регистра oPos. Двенадцатая команда дописывает в компоненты z и w этого регистра значение 0 и 1. И, наконец, тринадцатая команда записывает в выходной регистр цвета цвет текущей вершины из регистра v1. Рисунок 5.20 резюмирует весь вышеприведенный анализ кода, устанавливая соответствие между ассемблерным и HLSL кодом шейдера. Однако следует ясно осознавать, что это всего лишь код для виртуального вершинного процессора, который будет скомпилирован драйвером видеокарты в код для конкретного реального вершинного процессора. А архитектура физического вершинного процессора может иметь множество нюансов. Например, все вершинные процессоры современных видеокарт являются суперскалярными и могут запускать несколько ассемблерных инструкций за такт, в частности вершинные процессоры R3xx и R4xx могут выполнить за один такт одну векторную инструкцию над 1-4 компонентным
вектором и одну скалярную инструкцию (если они, разумеется, не зависят друг от друга по данным). Поэтому оптимизирующий компилятор драйвера видеокарты может переставлять инструкции местами для достижения большего параллелизма.
Рисунок 5.20. Соответствие между листингом Vertex Shader 1.1 и HLSL.
Кроме того, многие вершинные процессоры имеют расширенный набор инструкций по сравнению со спецификаций Vertex Shader 1.1. Так вершинные процессоры R2xx могут аппаратно вычислять дробную часть числа, соответственно если драйвер видеокарты в процессе компиляции эффекта в микрокод вершинного процессора обнаружит последовательности инструкций Vertex Shader 1.1, соответствующих макросу frc, то он заменит их одной встроенной командой. Другой пример: видеокарты R4xx и выше содержат инструкцию аппаратного вычисления синуса и косинуса угла в диапазоне –π .. +π, поэтому драйвер автоматически подменит разложение в ряд Тейлора вызовом данных встроенных функций. Тем не менее, оптимизирующий компилятор драйвера не всесилен, поэтому чем качественнее код Vertex Shader 1.1 и чем меньше явных атавизмов он содержит (вроде вычисления скалярного произведения серией инструкций add и mul вместо единственной инструкции dp4), тем вероятней драйвер видеокарты сможет сгенерировать оптимальный код. Таким образом, при написании эффекта в FX Composer 2.0 в качестве главного критерия оптимальности шейдера должен выступать не промежуточный код на языке Vertex Shader 1.1, а количество тактов графического процессора, затрачиваемых на обработку одной вершины. В частности на видеокарте GeForce 7800 GTX обработка одной вершины нашим вершинным шейдером занимает 20 тактов, а всего за одну секунду еѐ вершинный процессор теоретически может обработать 172.000.000 вершин. Анализ ассемблерного кода тоже весьма полезен, но в первую очередь как средство поиска проблемных мест в коде эффекта, нуждающегося в оптимизации. Но при этом не следует забывать об алгоритмической оптимизации приложения на макроуровне, иначе зациклившись на оптимизации нескольких локальных выражений вы рискуете не увидеть за деревьями леса. В частности, в следующем практическом упражнении демонстрируется, как использование знаний школьного курса тригонометрии позволяет значительно повысить производительность приложения.
Практическое упражнение №5.1 Вершинный шейдер, выполняющий вращение диска, рассчитывает для каждой вершины значение тригометрических функций sin и cos. Следовательно, при визуализации множества дисков, содержащих тысячи вершин, нам придется рассчитать для каждого кадра значения тысяч тригонометрических функций. Учитывая, что вычисление тригонометрической функции является весьма трудоемким процессом, данное обстоятельство может негативным образом влиять на производительность. П р им еч а н ие Графические процессоры G8x и R6xx содержат массив универсальных процессоров, которые могут выполнять код как вершинных, так и пиксельных шейдеров. Баланс между процессорами, выполняющих код вершинного шейдера и процессорами, выполняющих пиксельный шейдер, регулируется динамически в зависимости от загруженности соответствующих блоков видеокарты. При этом неоправданно сложный вершинный шейдер неминуемо “оттянет на себя” дополнительное количество универсальных процессоров и замедлит выполнение пиксельных шейдеров.
Обратим внимание на один нюанс. Аргумент функций sin и cos является уникальным для каждой вершины, причем он все время меняется. Но формируется он путем сложения двух компонентов: float a = input.pos.y + angle;
При этом значение input.pos.y является постоянным для каждой вершины, а значение angle хотя и изменяется, но является общим для всех вершин. Таким образом, значения sin(input.pos.y) и cos(input.pos.y) вполне можно было бы рассчитать заранее в обработчике события Load и передавать в вершинный шейдер как координаты вершины, а sin(angle) и cos(angle) как входные параметры вершинного шейдера. Чтобы это стало возможным, необходимо выразить косинус суммы и синус суммы через синусы и косинусы слагаемых, воспользовавшись известными формулами из школьного курса тригонометрии: sin(α+β)=sin(α)cos(β)+cos(α)sin(β)
(5.5)
cos(α+β)=cos(α)cos(β)-sin(α)sin(β) Задание: проведите оптимизацию эффекта примера Ch05\Ex06, реализовав вычисление тригометрических функций посредством выражения 5.5, и выноса большей части бессмысленных трудоемких расчетов за пределы вершинного шейдера. Используя NVIDIA FX Composer 2.0, оцените потенциальный прирост производительности (который, скорее всего, окажется более чем трехкратным). П о дс ка зка Имея вектор с предварительно рассчитанными значениями тригометрических функций, выражение 5.5 можно вычислить всего при помощи двух действий: по парного перемножения значения тригометрических функций и последующего суммирования результатов. Но так как вторая формула использует вычитание, для реализации еѐ через сумму вам потребуется передать в эффект значение –sin(angle). При этом чтобы облегчить работу оптимизирующему компилятору HLSL, входные параметры sin(angle), cos(angle), -sin(angle) желательно передавать в шейдер как один трехмерный вектор.
Если у вас возникнут трудности при выполнении данного задания, вы всегда можете ознакомиться с готовым решением, которое можно найти в каталоге \Examples\Ch05\Ex07.
5.5.2. Оператор if языка HLSL Следующий этап – перенос расчета полета искр в вершенный шейдер – является гораздо более сложным и запутанным, поэтому, чтобы восстановить силы мы сделаем небольшой привал и поговорим об особенностях оператора if языка HLSL применительно к профилю vs_1_1. Подобно подавляющему большинству языков программирования HLSL содержит конструкцию выбора if: if (логическое условие) { блок 1 } else { блок 2 }
В принципе, на этом раздел можно было бы окончить, если бы не одна маленький нюанс: язык Vertex Shader 1.1 не содержит команд для управления ходом выполнения программы (условные переходы, циклы и т.п.). Эта особенность имеет далеко идущие последствия. Когда в программе на C# используется конструкцию if, то центральный процессор будет каждый раз выполнять только одну из ветвей блока if, что позволяет значительно сократить объем вычислений и повысить производительность 85. А вот компилятор HLSL при использовании профиля vs_1_1 вынужден эмулировать условную конструкцию, генерируя код, выполняющий все ветви оператора с последующим комбинированием результатов. Соответственно, в HLSL применение оператора if в принципе не может поднять производительность приложения. В качестве примера попробуем “оптимизировать” вершинный шейдер, выполняющий закраску диска с использованием оператора if. Координаты вершины, распложенной в центре диска всегда неизменны, поэтому многие начинающие разработчики поддаются соблазну попытаться ускорить выполнение эффекта, отказавшись от трудоемкого расчета координат центральной вершины (листинг 5.13). Листинг 5.13. 85
Следует отметить, что современные процессоры с длинными конвейерами достаточно болезненно реагируют на хаотичные непредсказуемые условные переходы, поэтому при использовании оператора if следует соблюдать осторожность [К.12].
// Полный текст эффекта и готовое приложение находятся в каталоге Examples\Ch05\Ex08 VertexOutput MainVS(VertexInput input) { VertexOutput output; if (input.pos.x != 0) { // Если вершина не является центральной, рассчитываем еѐ координаты float a = input.pos.y + angle; output.pos.xy = input.pos.xx * float2(sin(a), cos(a)); } else // Если вершина расположена в центре круга, то еѐ координаты всегда равны (0.0, 0.0) output.pos.xy = float2(0.0, 0.0); output.pos.zw = float2(0.0, 1.0); output.color = input.color; return output; }
Ниже приведен отчет FX Composer с ассемблерным листингом кода: // // // // // // // // // // // // // // // // // // //
Generated by Microsoft (R) D3DX9 Shader Compiler 9.12.589.0000 Parameters: float angle;
Registers: Name Reg Size ------------ ----- ---angle c0 1
Default values: angle c0
= { 0, 0, 0, 0 };
vs_1_1 def c1, 0.159154937, 0.25, 0.5, -0.00138883968 def c2, 6.28318548, -3.14159274, -2.52398507e-007, 2.47609005e-005 def c3, 0.0416666418, -0.5, 1, 0 dcl_position v0 dcl_color v1 1. add r0.w, v0.y, c0.x 2. mad r1.xy, r0.w, c1.x, c1.yzzw 3. frc r0.xy, r1 4. mad r0.xy, r0, c2.x, c2.y 5. mul r0.xy, r0, r0 6. mad r1.xy, r0, c2.z, c2.w 7. mad r1.xy, r0, r1, c1.w 8. mad r1.xy, r0, r1, c3.x 9. mad r1.xy, r0, r1, c3.y 10. mad r0.xy, r0, r1, c3.z
11. 12. 13. 14. 15. 16.
mul mul slt mul mov mov
r0.w, v0.x, v0.x r0.xy, r0, v0.x r0.w, -r0.w, r0.w oPos.xy, r0, r0.w oPos.zw, c3.xywz oD0, v1
// approximately 18 instruction slots used
Для начала отметим, что количество ассемблерных команд возросло с 13 до 16, число микроинструкций с 15 до 18, а время выполнения шейдера с 20 до 21 тактов. Как видите, увеличение числа инструкций на 3 увеличило время выполнения шейдера всего на один такт. Эта “аномалия” имеет простое объяснение: каждый вершинный процессор G7x имеет VLIW-архитектуру86 и содержит два исполнительных блока87: один блок векторных операций (ADD, MUL, MADD и т.п.) и один блок скалярных операций (RCP, SIN, COS и т.п.). Следовательно, в идеальных условиях при отсутствии зависимостей по данным вершинный процессор G7x может за один такт запустить на выполнение одну векторную и скалярную операцию. Поэтому логично предположить, что драйвер NVIDIA при компиляции шейдера в микрокод вершинного процессора успешно спарил добавочные инструкции с остальными инструкциями шейдера. Хотя, конечно, нельзя исключить и влияние недокументированных особенностей микроархитектуры G7x. Таким образом, мы ещѐ раз убедились, что количество инструкций без учета нюансов архитектуры вершинного процессора не может являться универсальным критерием производительности. Перейдем к собственно ассемблерному коду вершинного шейдера. Первые 10 инструкций хорошо вам знакомы – они вычисляют сумму float a = input.pos.y + angle и значения тригометрических функций путем разложения в ряд Тейлора. По окончанию выполнения десятой команды в r0.x находится значение sin(a), а в r0.y значение cos(a). Одиннадцатая инструкция возводит input.pos.x в квадрат. Двенадцатая инструкция умножает input.pos.x на вычисленные значения тригометрических функций, завершая тем самым вычисления значения input.pos.xx * float2(sin(a), cos(a)). Тринадцатая команда выполняет сравнение значений -input.pos.xinput.pos.x и +input.pos.xinput.pos.x по следующему алгоритму: if (-input.pos.x* input.pos.x < input.pos.x* input.pos.x) r0.w=1 else r0.w=0
Нетрудно догадаться, что данный код всегда заносит в r0.w значение 1, если input.pos.x неравен 0, и 0 в противном случае. То есть, грубо говоря, оно эквивалентно r0.w = (input.pos.x!=0)
Теперь всѐ встало на свои места: так как Vertex Shader 1.1 не содержит инструкции сравнения на равенство двух чисел, сообразительный компилятор HLSL реализовал проверку числа на равенство 0 через комбинацию инструкций mul и slt. Дополнительная информация Язык Vertex Shader 1.1 не содержит инструкции проверки двух чисел на равенство, так как потребность в ней возникает достаточно редко. Дело в том, что Vertex Shader 1.1 поддерживает только типы с плавающей точкой, сравнение которых является достаточно нестабильной операцией: достаточно малейшей ошибки в последнем разряде и два равных значения перестанут быть равными. Но в нашем случае мы можем не опасаться каких-либо последствий потери точности: данная особенность типов half/float/double проявляется исключительно при использовании дробных чисел и обусловлено тем, что большинство конечных десятичных дробей при переводе в двоичную систему становятся бесконечными двоичными дробями. Так как разрядная сетка мантиссы конечна, эту дробь не удается точно представить и последующие операции над такими урезанными бесконечными дробями приводят к накоплению ошибки. Чтобы убедиться в наличии данной проблемы достаточно провести небольшой вычислительный эксперимент в консольном приложении C#: // Код примера расположен в Examples\Ch05\Ex09 class Program 86
Very Long Instruction Word (VLIW) – архитектурная особенность процессора с явным параллелизмом, к которых команды состоят из микроинструкций, определяющих операцию для каждого функционального устройства процессора. Примером процессора с VLIW-архитектурой является Intel Itanium 87 В действительности исполнительных блоков несколько больше, но ограниченная функциональность Vertex Shader 1.1 позволяет задействовать только эти два блока.
{ static void Main(string[] args) { float a = 1.2f; float b = 1.4f; float c = 1.68f; float mul = a * b; float delta = c - mul; Console.WriteLine("a = " Console.WriteLine("b = " Console.WriteLine("c = " Console.WriteLine("sum = Console.WriteLine("delta Console.ReadKey();
+ + + " =
(double)a); (double)b); (double)c); + (double)mul); " + (double)delta);
} }
После выполнения примера на экране появится следующая информация: a = 1,20000004768372 b = 1,39999997615814 c = 1,67999994754791 sum = 1,6800000667572 delta = -1,19209289550781E-07
Как видно, значения 1.2 и 1.4 не могут быть точно представлены в двоичной системе, в результате чего результат 1.68 – 1.21.4 оказался равен не нулю, а отрицательному числу -1.1910-7. Таким образом, с точки зрения компьютера 1.68≠1.21.4. Ещѐ раз хочу обратить ваше внимание, что данные парадоксы возникают исключительно при работе с дробными числами, которые часто (но не всегда) не могут быть точно переставлены в двоичной системе. Целые же числа всегда могут быть точно переведены в двоичное представление, поэтому работа с ними осуществляется без потери точности88. В частности если мы предварительно умножим a и b на 10, то значение 1214 окажется в точности равно 168: a = 12 b = 14 c = 168 sum = 168 delta = 0
П р им еч а н ие Некоторые новые графические процессоры, такие как G8x, содержат инструкцию, позволяющую выполнять покомпонентное сравнение четырехмерных векторов. Соответственно, драйвера при компиляции кода шейдера в микрокод подменяет последовательность инструкций вроде mul/slt одной инструкцией сравнения.
Четырнадцатая инструкция умножает рассчитанные координаты x и y вершины на результат сравнения input.pos.x с 0: если input.pos.x!=0, то координаты остаются без изменения, а если input.pos.x==0, то они обнуляются. Таким образом, условное выражение if (input.pos.x != 0) { ... } else output.pos.xy = float2(0.0, 0.0);
реализуется последовательностью инструкций mul / slt / mul. Резюмируем всѐ вышесказанное. Компилятор HLSL успешно справился с реализацией оператора if без использования условных инструкций, но на производительности приложения это сказалось отрицательно, хотя и не фатально (на G7x время выполнения вершинного шейдера возросло на один такт).
88
Разумеется, при условии достаточной разрядности мантиссы.
5.5.3. Искры После небольшого лирического отступления перейдем к моделированию полета искр средствами вершинного шейдера. Для начала освежим в памяти алгоритм генерации новых искр. Логика работы хранителя экрана выполняется с фиксированным шагом timeStep. В течение каждого дискретного шага появляется случайное количество новых искр, но не более maxScintillaCount. Каждая искра имеет случайные координаты, а так же случайную угловую и прямолинейную скорости. Время жизни каждой искры равно StartTime, по прошествии которого искра считается потухшей и еѐ структура может использоваться для генерации новой вершины: новые искры замещают потухшие, и лишь при отсутствии свободных потухших искр информация добавляется в конец массива вершин. Данный алгоритм весьма проблематично реализовать в вершинном шейдере. Дело в том, что вершинный процессор обрабатывает вершины параллельно, независимо друг от друга, в результате чего вершинный шейдер не может получить информацию о состоянии других вершин. Ну а так как состояние шейдера не сохраняется между вызовами, а вернуть информацию из вершинного шейдера очень проблематично89, приложение не сможет отслеживать состояние каждой вершины и соответственно определять число потухших вершин, их индексы в массиве вершин и т.д. и т.п. Поэтому для генерации новых вершин нам придется разработать новый алгоритм, удовлетворяющим двум требованиям: Постоянно изменяющиеся параметры должны быть общими для всех вершин, чтобы их можно было передавать через константные регистры. Обработка вершин должна идти параллельно и независимо. При этом отсутствует возможность сохранения промежуточных значений между вызовами вершинного шейдера. Будучи зажатыми в такие жесткие рамки, мы вряд ли сможем реализовать полноценный алгоритм генерации случайных искр со случайными параметрами. Поэтому мы просто заранее рассчитаем на некотором небольшом временном интервале время появления всех искр, их координаты, скорости, цвета и т.п., после чего будем проигрывать эту последовательность “по кругу” (рисунок 5.21). Таким образом, траектория движения точек будут повторяться через некоторое время, но если длительность одной итерации будет измеряться в десятках секунд, а число искр тысячами, то пользователь вряд ли сможет заметить какую-либо цикличность в поведении искр.
Итерация
0
Итерация
Итерация
Итерация
Длительность итерации
время
Рисунок 5.21. Циклическая работа фейерверка.
Минусом данного подхода является необходимость расчета в каждом кадре всех искр массива вершин, включая потухшие искры, причем, чем сильнее продолжительность итерации превосходит время жизни вершины, тем выше будут накладные расходы. Хотя с другой стороны этот недостаток наверняка компенсируется огромной производительностью вершинного процессора 90. Время, используемое при расчете траектории движения вершины, будет вычисляться кодом следующего вида: currentTime = time – vertexStartTime; localTime = currentTime % timeLoop;
где
time – время, прошедшее с момента запуска приложения.
vertexStartTime – время появления вершины, отсчитываемое он начала итерации. После запуска
хранителя экрана идет первая итерация, то есть время отсчитывается от нуля.
% – операция нахождения остатка от деления.
timeLoop – длительность итерации, то есть время, через которое полет вершины повторяется заново.
89
Для этого необходимо выполнить визуализацию во временное изображение (текстуру) и скопировать его из видеопамяти в оперативную память. Но так как данная операция является очень медленной, еѐ стараются использовать только в случае крайней необходимости. 90 Кроме того, в следующей главе мы примем ряд мер, призванных повысить эффективность обработки неиспользуемых вершин, в результате чего данный недостаток сведется к минимуму.
localTime – локальное время искры внутри итерации.
На рисунке 5.22 приведен график зависимости currentTime от time, построенный в Mathcad. Как видно, при запуске приложения время может быть равно отрицательному значению – это означает, что искра ещѐ не появилась на экране. Далее по мере увеличения time локальное время так же линейно возрастает, пока не достигнет значения timeLoop, после чего оно сбрасывается до нуля и всѐ повторяется сначала.
Рисунок 5.22. График зависимости локального времени искры от времени с момента запуска приложения.
Разобравшись с организацией массива вершин, займемся непосредственно моделированием полета искр. Как вы помните, в хранителе экрана из четвертой главы траектория движения искры складывается как композиция движения искры из центра диска по прямой с постепенным замедлением и движения по окружности вокруг диска с постоянно уменьшающейся угловой скоростью. Собственно расчет траектории выполнялся “в лоб” путем грубой аппроксимации с малым шагом времени delta: // Корректируем скорость прямолинейного движения. При этом вершина не должна начинать // двигаться в противоположном направлении tSpeed = Math.Max(tSpeed - tSlowing * delta, 0.0f); // Корректируем скорость вращательного движения rSpeed = Math.Max(rSpeed - rSlowing * delta, 0.0f); // Изменяем расстояние искры от центра диска distance += tSpeed * delta; // Изменяем угол поворота искры вокруг диска angle += rSpeed * delta;
Так как этот алгоритм использует рекуррентные выражения, ссылающиеся на результаты предыдущих расчетов, он не может быть использован в вершинном шейдере – для этого потребуется сохранять
результаты работы вершинного шейдера между визуализацией кадров, что весьма проблематично. Поэтому нам необходимо избавиться от реккурентности. В качестве основы возьмем выражение 𝑡𝑆𝑝𝑒𝑒𝑑 = 𝑡𝑆𝑝𝑒𝑒𝑑0 − 𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙ 𝑡 где
tSpeed – скорость вершины;
tSpeed0 – начальная скорость вершины;
tSlowing – замедление вершины;
t – локальное время вершины (localtime).
Расстояние, пройденное вершиной, может быть найдено посредством суммирования значений (𝑡𝑆𝑝𝑒𝑒𝑑0 − 𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙ 𝑡) ∙ 𝑑𝑡, где 𝑑𝑡 – интервал времени, стремящийся к нулю. А это есть не что иное, как интеграл 𝑑𝑖𝑠𝑡𝑎𝑛𝑐𝑒 =
(𝑡𝑆𝑝𝑒𝑒𝑑0 − 𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙ 𝑡) ∙ 𝑑𝑡 = 𝑡 ∙ 𝑡𝑆𝑝𝑒𝑒𝑑0 −
где
𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙ 𝑡 2 +𝐶 2
C – константа
Значение этой константы можно легко определить из соображений, что при t=0 расстояние должно быть равно начальному положению точки 𝑑𝑖𝑠𝑡𝑎𝑛𝑐𝑒0 : 𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙ 02 +𝐶 =𝐶 2 Таким образом, константа C равна начальному положению точки и в результате мы получаем окончательную формулу: 𝑑𝑖𝑠𝑡𝑎𝑛𝑐𝑒0 = 0 ∙ 𝑡𝑆𝑝𝑒𝑒𝑑0 −
𝑑𝑖𝑠𝑡𝑎𝑛𝑐𝑒 = 𝑑𝑖𝑠𝑡𝑎𝑛𝑐𝑒0 + 𝑡𝑆𝑝𝑒𝑒𝑑0 ∙ 𝑡 −
𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙𝑡 2
(5.6)
2
Но это ещѐ не всѐ – выражение 5.6 построено на предположение, что по достижению скоростью искры значения 0 она начинает двигаться в противоположную сторону с возрастающей скоростью, в то время как наши искры по достижению нулевой скорости должны останавливаться на месте. Для учета данного обстоятельства мы должны ограничить значение времени величиной, при котором скорость становится равна 0: 𝑡𝑑 = min(localtime,
𝑡𝑆𝑝𝑒𝑒𝑑 0 𝑡𝑆𝑙𝑜𝑤𝑖 𝑛𝑔
)
𝑑𝑖𝑠𝑡𝑎𝑛𝑐𝑒 = 𝑑𝑖𝑠𝑡𝑎𝑛𝑐𝑒0 + 𝑡𝑆𝑝𝑒𝑒𝑑0 ∙ 𝑡𝑑 −
(5.7) 𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙ 𝑡𝑑 2
2
где 𝑡𝑑 – локальное время искры, которое не может превышать значение, при котором скорость объекта становится отрицательной. Выражение для вычисления угла поворота вершины отличается от выражения расчета расстояния вершины от центра диска лишь несколькими нюансами, поэтому я сразу приведу готовый результат: 𝑡𝑆𝑝𝑒𝑒𝑑 0 𝑡𝑟 = min (localtime, ) (5.8) 𝑡𝑆𝑙𝑜𝑤𝑖𝑛𝑔
𝑎𝑛𝑔𝑙𝑒 = 𝑑𝑖𝑠𝑘𝑆𝑝𝑒𝑒𝑑 ∙ 𝑡𝑖𝑚𝑒 − 𝑙𝑜𝑐𝑎𝑙𝑇𝑖𝑚𝑒 + 𝑎𝑛𝑔𝑙𝑒0 + 𝑡𝑟 ∙ 𝑟𝑆𝑝𝑒𝑒𝑑0 − где 𝑙𝑜𝑐𝑎𝑙𝑇𝑖𝑚𝑒 – локальное время вершины;
𝑟𝑆𝑙𝑜𝑤𝑖𝑛𝑔 ∙ 𝑡𝑟 2 2
𝑡𝑟 – локальное время с учетом остановки вершины по достижению скоростью нулевого значения;
𝑎𝑛𝑔𝑙𝑒 – угол поворота искры вокруг диска;
𝑑𝑖𝑠𝑘𝑆𝑝𝑒𝑒𝑑 – скорость вращения диска;
𝑡𝑖𝑚𝑒 – текущее время;
𝑎𝑛𝑔𝑙𝑒0 – локальный угол поворота вершины вокруг диска (без учета вращения самого диска);
𝑟𝑆𝑝𝑒𝑒𝑑0 – начальная уголовная скорость вершины (она меньше скорости диска);
𝑟𝑆𝑙𝑜𝑤𝑖𝑛𝑔 – замедление вершины.
Искра вылетает все время из одного и того же места диска, но так как диск постоянно вращается, начальный угол поворота вершины всѐ время оказывается разным. Такой подход имеет два достоинства: цвет вершины остается постоянным, а сами траектории движения вершин становятся более хаотичными. Для учета угла поворота диска в момент появления вершины используется слагаемое 𝑑𝑖𝑠𝑘𝑆𝑝𝑒𝑒𝑑 ∙ 𝑡𝑖𝑚𝑒 − 𝑙𝑜𝑐𝑎𝑙𝑇𝑖𝑚𝑒 , являющееся постоянным на протяжении всей жизни вершины (т.е. до следующей итерации).
Как видно, адаптация алгоритма с учетом специфики вершинного процессора может быть весьма нетривиальной задачей. Теперь можно приступать реализации данного алгоритма, но так как используемые формулы являются весьма запутанными и громоздкими, их не помешает для начала опробовать в классеэмуляторе вершинного шейдера в C#. Кроме того, это позволит нам впоследствии оценить потенциальный прирост производительности, которого можно достичь при переносе вычислений в вершинный шейдер.
Прототип вершинного шейдера Как и в случае с эффектом, вращающим диск, мы начнем с реализации класса, инкапсулирующего вершинный шейдер. Для начала определимся с параметрами, принимаемыми эффектом и способом их передачи (таблица 5.5). В эффекте визуализации вращающегося диска входные параметры, уникальные для каждой вершины, передавались через координаты вершины. Но эффект визуализации искр содержит заметно больше входных параметров, поэтому нам придется передавать часть параметров через текстурные координаты91. Применение текстурных координат никоим образом не сказывается точности передаваемых значений, ведь они, как и координаты и цвета вершин, проецируются компилятором HLSL на универсальные входные регистры v0, v1 … v15. Код класса, эмулирующего работу вершинного шейдера, приведен в листинге 5.14. Таблица 5.5. Входные параметры вершинного шейдера, поведение искры Описание параметра
Аналогичный параметр из формул предыдущего раздела
Общий для всех вершин
Место хранения
Текущее время
time
Да
Входной параметр time
Длительность “итерации”, в течении которой движения искр не повторяются
timeLoop
Да
Входной timeLoop
параметр
Скорость вращения диска
diskSpeed
Да
Входной diskSpeed
параметр
Цвет искры
–
Нет
Цвет вершины
Время появления искры
vertexStartTime
Нет
Координата X
Начальное расстояние вершины от центра диска
distance0
Нет
Координата Y
Локальный угол поворота вершины
angle0
Нет
Координата Z
Начальная скорость удаления вершины от центра
tSpeed
Нет
Текстурная координата X
Начальная угловая скорость вершины
rSpeed
Нет
Текстурная координата Y
Листинг 5.14. static class FireworkEffect { // Константы с замедлениями искр. Общие для всех вершин const float tSlowing = 0.105f; const float rSlowing = 0.25f; // Константа времени жизни искр const float liveTime = 4.0f; // Входные public public public
параметр time, timeLoop, diskSpeed static float time; static float timeLoop; static float diskSpeed;
// Код вершинного шейдера. 91
Использование текстур будет подробно рассмотрена в седьмой главе.
// //
// // // //
// // //
input – входные данные вершины, output – выходные данные вершины public static void VertexShader(VertexPositionColorTexture[][] input, VertexPositionColor[][] output) { Перебираем все вершины (в реальном вершинном шейдере этих циклов не будет). Так как при тестировании производительности число вершин может превысить лимит примитивов, которые может визуализировать за один проход GPU Intel GMA9xx, используется несколько массивов вершин for (int j = 0; j < input.Length; j++) { for (int i = 0; i < input[j].Length; i++) { Вычисляем время, прошедшее с первого появления искры float currentTime = time - input[j][i].Position.X; Вычисляем локальное время, циклически пробегающее от 0 до timeLoop float localTime = currentTime % timeLoop; Определяем время, которое осталось существовать искре float remainTime = liveTime - localTime;
// Ограничиваем величину локального времени, чтобы искра останавливалась по достижению // нулевой скорости float td = Math.Min(localTime, input[j][i].TextureCoordinate.X / tSlowing); // Вычисляем расстояние вершины от центра диска float distance = input[j][i].Position.Y + td * (input[j][i].TextureCoordinate.X - td * tSlowing / 2.0f); // Ограничиваем величину локального времени, чтобы искра останавливалась по достижению // нулевой угловой скорости float tr = Math.Min(localTime, input[j][i].TextureCoordinate.Y / rSlowing); // Вычисляем текущий угол поворота искры вокруг диска float angle = input[j][i].Position.Z + diskSpeed * (time - localTime) + tr * (input[j][i].TextureCoordinate.Y - tr * rSlowing / 2.0f); // Вычисляем координаты вершины на основе расстояния и угла поворота output[j][i].Position.X = distance * (float)Math.Sin(angle); output[j][i].Position.Y = distance * (float)Math.Cos(angle); output[j][i].Position.Z = 0.0f; // Если искра появилась на экране, но ещѐ не потухла if ((currentTime >= 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: gsaf@sura.ru. Не стоит забывать, что данная книга является лишь вводным руководством в мир 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.