Текст
                    Грег Снук
ЗП-ландшафты
в реальном времени на C++ и DirectX 9
♦ Создание полнофунк-
циональных ЗО-движ-
ков (ландшафтов) с
начала и до конца
♦ Обсуждение ключе-
вых тем, таких как
разработка движка на
C++, математические
и геометрические
основы, DirectX 9,
анимация и осве-
щение, а также
различные эффекты
ф Показ разработки
реалистических
вершинных и пик-
сельных шейдеров
для внешнего осве-
щения поверх.ностей
с помощью нового
языка DirectX High
Level Shading
Language (HLSL)
КУДИЦ-ОБРАЗ
В помощь
разработчику игр

Эта книга посвящена моей супруге Денизе и моим детям Мэдлин, Бену и Джону - за невероятное терпение и поддержку
Real-Time 3D Terrain Engines using C++ and DirectX®9 Greg Snook CHARLES RIVER MEDIA Charles River Media, Inc. Hingham, Massachusetts
Создание 3D-ландшафтов в реальном времени с использованием С++ и DirectX 9 Грег Снук КУДИЦ-ОБРАЗ Москва • 2007
ББК 32.973.26-018.2 Грег Снук Создание ЗО-ландшафтов в реальном времени с использованием C++ и DirectX 9 / Пер. с англ. - М.: КУДИЦ-ОБРАЗ, 2007. - 368 с. Хотите ли вы создать свою игру в жанре стратегии в реальном времени или симуля- тор земной поверхности - данная книга будет вам верным помощником. А может быть, вы хотите превзойти в терраморфинге создателей «Периметра»? Тогда эта книга может стать для вас отправной точкой в нелегком пути. Шаг за шагом вы будете изучать по- строение ландшафтного движка с использованием DirectX 9 и C++. Вы узнаете, как применять пиксельные и вершинные шейдеры, а также о методиках текстурирования ландшафта, об имитации реалистичных гор, долин, неба, водных поверхностей. Создайте свой собственный мир! К данной книге прилагается CD-диск. Грег Снук Создание 3D-ландшафтов в реальном времени с использованием C++ и DirectX 9 Учебно-справочное издание Перевод с англ. А. В. Петров Научный редактор И. В. Кошечкин Корректор В. Г. Клименко Макет С. В. Красильникова «ИД КУДИЦ-ОБРАЗ» 119049, Москва, Ленинский пр-т., д. 4, cip. 1 А. Подписано в печать 01.10.06 Отпечатано с готовых диапозитивов Формат 7.0x90/16 в ОАО «Щербннская типография» Печать офс. Бумага газ. 117623, Москва, ул. Типографская, д. 10 Уел печ л 26,9 Тираж 1000 Зака< 1839 Т. 659-23-27. ISBN 1 -58450-204-5 © 2003 by CHARLES RIVER MEDIA, INC ISBN 5-9579-0090-7 (рус.) © Перевод, макет и обложка «ИД КУДИЦ-ОБРАЗ», 2006,2007 Copyright © 2003 by CHARLES RIVER MEDIA, INC. Translation Copyright © 2005 by Kudits-Obraz. All rights reserved Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. Все названия программных продуктов являются зарегистрированными торговыми марками соответствующих фирм. Все права защищены. Русское издание опубликовано издательством КУДИЦ-ОБРАЗ, © 2005,2007.
ВВЕДЕНИЕ С тех пор как в начале 1950-х гг. на экране компьютера появились первые точки, которыми управляла машина, элитой сообщества программистов овладело желание найти способ, как заставить эти точки охотиться, сражаться, обманывать и уничтожать друг друга. Мы называем таких людей «программистами игр». Под их руководством компонен- ты первых компьютеров, созданные для высадки человека на Луну и управления ядерны- ми реакторами, скоро стали выполнять более важную задачу - участвовать в играх. Пио- неры этого направления, среди которых были Вилли Хиггинботэм (Willy Higginbotham) и Э. С. Дуглас (A. S. Douglas), превращали груды осциллографов, электронных ламп, рукояток и кнопок в машины, которые могли играть в теннис и крестики-нолики. Немно- гим более 10 лет спустя Ральф Бэр (Ralph Baer) придумал, как подключить к играм под управлением компьютера телевизионный экран, и появились первые машины, предна- значенные специально для игр. Первопроходцы компьютерных игр дали определение тому, что такое компьютерная игра сегодня: игра - это способ сделать из дорогой диковины, способной считать, забаву, способную нас развлечь. От поколения к поколению компьютеры становились мощнее, а игры все усложнялись. Сегодня программирование компьютерных игр - это настоящий выбор профессии, и в нашем распоряжении находится целый арсенал игровых машин и платформ. Мы достигли черты, когда покупка многих компьютеров обусловлена жела- нием развлекаться, а для создания ЗВ-миров, в которых мы можем играть, существует спе- циальная видеотехника. Имея своей целью ввести начинающих в курс написания трехмерных компьютерных игр, эта книга посвящена рассказу об основах одного типа популярных ЗВ-движков: соз- данию ЗВ-ландшафтов в режиме реального времени. Кем бы вы ни были - новичком в программировании ЗВ-движков или опытным разработчиком, - эта книга научит вас применять последние достижения в области визуализации с поддержкой аппаратного ускорения для создания в реальном времени и с высокой детализацией ЗВ-ландшафта, который вы сможете использовать в ваших проектах. По ходу книги, чтобы дать читателю понятие об идеях, лежащих в основе систем трехмерных ландшафтов, и о методах, используемых для их представления, мы построим такой ЗВ-движок от начала и до конца.
6 ВВЕДЕНИЕ Аудитория Эта книга написана для программистов, знакомых с языком C++, Microsoft® DirectX®, пространственной математикой и геометрией. И хотя в ней представлен обзор основных тем, связанных с ЗО-математикой и применением DirectX, книга не задумывалась как введение в программирование ЗО-задач, нет в ней и полного описания основ программирования в DirectX и для Microsoft Windows®. Мы полагаем, что читатель уже овладел этими базовыми навыками и готов двигаться дальше в созда- нии ЗО-движков и визуализации ландшафта в режиме реального времени. При необхо- димости получить более подробную информацию вводного характера ссылки на материалы для изучения даны в приложении D «Рекомендуемая литература». z-'c, На компакт-диске, прилагаемом к книге, вы найдете полную версию инстру- ч'-~-—ментария (SDK) для Microsoft DirectX 9.0. В SDK входят материалы для обуче- ния и документация, призванная стать для программистов введением в мир 3D и работу при помощи DirectX. Если вы не знакомы с этими вопросами, мы рекомендуем вам озна- комиться с информацией, изложенной в SDK, и внимательно изучить поставляемые с ним обучающие примеры. Аппаратные и программные требования Основное внимание в этой книге уделено программированию ландшафтов с использованием C++ и DirectX 9.0 в реальном масштабе времени. Полный комплект DirectX 9.0 SDK находится на прилагаемом компакт-диске, однако читатель должен установить компилятор, способный откомпилировать исходный код на языке C++, написанный для работы с этими библиотеками. Примеры кода на CD-ROM были написа- ны при помощи Microsoft Visual Studio.NET® (Visual Studio 7.0), и для работы с книгой рекомендуется именно такой компилятор. Для удобства на диске приведены файлы проек- тов для Microsoft Visual Studio® версии 6.0. Компиляторы других фирм, вероятно, также смогут откомпилировать эти файлы, однако такие тесты не проводились. Запуск программ, поставляемых с этой книгой, требует подходящей машины. Мини- мальное требование для ЦП - поддержка команд Intel® Streaming SIMD Extensions (SSE), реализованная в процессорах Intel Pentium® III (или выше), а также в семействе процессоров Athlon™ компании AMD®. Минимальная рекомендуемая тактовая частота ЦП-1 ГГц, минимальный рекомендуемый объем ОЗУ - 256 Мб. Кроме того, вам понадобится видеокарта, которая поддерживает пиксельные и вершинные шейдеры с возможностью аппаратного ускорения, удовлетворяющие набору стандартов DirectX 9.0. Среди прочих к числу таких видеокарт относятся карты на основе набора микросхем NVIDIA® GeForce™ (версии 3, 4, FX или выше) и ATI® Radeon™ (серия 8500 или выше). J3 ряде примеров шейдеров используются самые последние возможности
ВВЕДЕНИЕ ______________________________________ _________________ 7 языка для работы с программируемыми шейдерами, и для работы вам может понадобиться более мощная видеокарта. Наконец, по мере того как по ходу книги мы будем добавлять в движок новые возможности, слабые машины вкупе с такими же видеокартами могут не обеспечить частоту кадров, необходимую для настоящей интерактивности. Каждый используемый в DirectX программируемый вершинный и пиксельный шейдер имеет собственную спецификацию языка, обозначаемую номером версии. Этот номер отличен от номера версии DirectX SDK. Часть шейдеров, предложенных в этой книге, тре- бует последних версий языка вершинных и пиксельных шейдеров (vs 2.0 и ps 2.0), которые сейчас поддерживают такие видеокарты высшего класса, как NVIDIA GeForce FX и ATI Radeon 9700. Там, где только возможно, для понижения требований к аппаратуре мы будем давать обратно совместимые версии таких шейдеров. Следует также заметить, что выход DirectX 9.0 ознаменовал конец поддержки DirectX для Microsoft Windows 95. Исходный код, поставляемый с этой книгой, создан для Microsoft Windows ХР, однако сможет работать и в среде Windows 98, Windows ME или Windows 2000. Тем не менее поддержка этих - более старых - версий Microsoft Windows не гарантируется, поскольку драйверы DirectX 9.0 могут быть недоступны для некоторых видеокарт в этих более старых операционных системах. Работа с книгой На сегодняшний день существует множество книг по программированию движков ЗО-игр, однако авторы большинства из них либо избрали чересчур общий подход, либо сосредоточили внимание на отдельных приемах и трюках. И хотя все идеи, представлен- ные в таких книгах, невероятно полезны, попытки включить набор подобных идей в единый проект всегда пугали читателей. В этой книге выбран другой подход; в ней мы сконцентрируем свое внимание непосредственно на вопросах рендеринга открытого, не ограниченного стенами ландшафта и представим все приемы и хитрости в контексте единого игрового движка. По ходу книги для изучения важнейших аспектов рендеринга ландшафта мы будем строить законченный ландшафтный ЗО-движок, начав с самой земли (игра слов не случайна). А для того чтобы упростить движок еще больше, мы будем пользоваться средствами Sample Framework, предоставляемыми DirectX SDK, и библиотекой Direct3D Extension Library (D3DX). Хотя D3DX нечасто применяется в коммерческих играх, она даст нам удобный, хорошо документированный фундамент, которым мы будем пользо- ваться в нашем движке. Используя преимущества этой библиотеки, мы сможем напрямую перейти к вопросам, касающимся ландшафта, избавив себя от необходимости создания своих собственных математических и геометрических библиотек низкого уровня.
8_________________, ВВЕДЕНИЕ с Несмотря на то что прилагаемый к книге компакт-диск содержит исходный код 4-----движка, который мы будем строить, мы настоятельно рекомендуем вам по мере чтения книги писать свой собственный код. Исходный код на CD-ROM дается вам для работы и справки, однако нет лучше способа освоить идеи программирования, чем само- стоятельно их закодировать. По мере изучения каждой главы включайте изложенные идеи в свой собственный движок, используя текст и CD-ROM как опорные материалы. Закончив чтение книги, вы обнаружите, что ваша голова полна методов рендеринга и про- граммистских идей, а у вас есть готовый движок ЗО-игр на открытом пространстве вашей собственной разработки. На деле разработка движка не является линейной задачей. И хотя существует общий порядок его создания, которому мы можем следовать, при построении игрового движка мы будем часто возвращаться к ранее написанным фрагментам для добавления в них новых возможностей. В отдельных случаях это будет означать переход от файла к файлу и повторное написание кода, который, как вы могли подумать, уже готов. Мы будем так поступать для того, чтобы немедленно увидеть свой новый код в действии, зная, что позд- нее сможем вернуться к нему для введения дополнительных функций. Альтернативой этому, которую мы время от времени будем сносить, станут долгие и скучные стадии, на протяжении которых мы станем добавлять много нового кода, но на экране не будем видеть почти никаких изменений в движке. Так или иначе, в конце пути все это окупится, и надо лишь немного потерпеть, чтобы достичь результата. Исходный код, прилагаемый к книге, содержит также дополнительные служебные классы, созданные, чтобы немного упростить программирование движка. Этот вспомога- тельный код предоставляет нам описания типов и множество средств манипуляции дан- ными и управления памятью. В этом наборе имеются и средства отладки и профилирова- ния. Для краткости изложения эти служебные классы не обсуждаются в книге во всех деталях. Исходный код классов снабжен множеством комментариев, которые помогут читателю при его изучении. Краткий обзор этих библиотек ядра и лаконичное описание реализуемых ими функций содержит приложение А «Служебные классы Gaia». За разъяс- нениями относительно служебных классов, которые вам будут встречаться, обращайтесь - по мере чтения исходного кода на прилагаемом диске - к приложению А. с Многие идеи и алгоритмы, объяснение коих приводится в этой книге, показаны вместе с соответствующим исходным кодом. Однако на страницах издания разме- щен не весь код1. Считайте, что код на прилагаемом компакт-диске служит продолжением 1 Поскольку листинг всего кода занял бы слишком много места в книге. Да и наверное, мало желающих найдется вручную его перепечатывать в среду разработки. Гораздо проще взять готовый проект с компакт диска или интернет-сайта книги. - Примеч. науч. ред.
ВВЕДЕНИЕ 9 книги. CD-ROM дается вам для того, чтобы вы на досуге изучили остальной код и вас посе- тили идеи, которых вам не могла дать сама книга. Мы считаем исходный код неотъемлемой частью книги и часто будем отсылать вас к нему за более подробными сведениями. Как устроена книга Книга поделена на три главные части, каждая из которых нацелена на конкретную группу проблем. По ходу книги мы будем строить движок и сделаем это за три основные стадии: фундамент, базовая функциональность и конечный продукт. Эти стадии также обозначают порядок, в котором мы будем изучать вопросы ландшафтного синтеза. Снача- ла мы рассмотрим DirectX 9.0 и выстроим наши фундаментальные классы, затем постро- им базовый ландшафтный движок и, наконец, добавим те функции, которые расширят его и вдохнут жизнь в наш внеинтерьерный пейзаж. Попутно для представления наших успе- хов мы создадим множество мелких демонстрационных программ. В открывающих первую часть «Основы трехмерной графики» главах мы изучим основы DirectX 9 и библиотеку D3DX. Также мы обсудим язык описания шейдеров высо- кого уровня (HLSL, High-Level Shader Language) - спецификацию нового языка, включен- ного в состав DirectX 9.0 и дающего возможность создавать программируемые шейдеры без применения ассемблерного кода, традиционного для вершинных и пиксельных шей- деров. Все вершинные и пиксельные шейдеры, созданные в этой книге, будут использо- вать HLSL, поэтому данному языку мы отведем целую главу книги. Опираясь на этот фундамент, для удовлетворения базовых нужд нашего движка мы начнем строить свои классы поверх DirectX и D3DX. В последних главах первой части мы создадим первую версию движка и напишем служебное приложение, которое позво- лит нам видеть модели и анимации, использующие HLSL-шейдеры. Во второй части «Введение в системы ландшафтного синтеза» мы сосредоточим свое внимание на основных потребностях, характерных для ландшафтных движков: реализа- ции ландшафтной геометрии как таковой и текстур, которые на нее будут наложены. В начальной главе части мы изучим методы пространственной организации данных нашего мира с применением метода, основанного на традиционных квадрадеревьях для разделения обширной площади ландшафта на более управляемые фрагменты. Мы изучим методы создания больших наборов элементов ландшафтной геометрии и управления ими, предполагающие наличие гибких уровней детализации, дающих возможность контро- лировать скорость рендеринга. В дополнение к традиционному подходу, основанному на «грубой силе», мы обсудим популярные методы управления ландшафтами: сетки с оптимальной подгонкой в реальном времени (ROAM) [Duchaineau] и блочный ландшафт [Ulrich]. Кроме того, мы расширим метод взаимосвязанных ландшафтных мозаик (ITT) [Snook] и продемонстрируем каждый из методов.
10 ВВЕДЕНИЕ Закончим же вторую часть главой о текстуризации ландшафта при помощи различных ^приемов. И хотя мы еще не ввели в наш движок ни реалистичное освещение, ни Атмосферные эффекты, эта часть книги обеспечит всю базовую функциональность, которая по служит для управления ландшафтной геометрией. На этой стадии мы создадим демо-программу, которая включит в себя организацию мира, текстуризацию и приемы менеджмента ландшафтов для создания вида пустого, бесплодного пейзажа. В третьей части «Дальнейшее развитие движка» наш мир начнет обретать форму. Удовлетворив базовые потребности ландшафта, мы сосредоточимся на том, чтобы доба- вить сцене реалистичности. В этой части мы обсудим последние достижения в методах наружного освещения, включая атмосферные эффекты. Используя эти идеи, мы построим окончательный конвейер рендеринга и начнем писать шейдеры, которые придадут нашему искусственному миру высокую степень реалистичности. Работая с небом и сол- нечным светом, мы обсудим методы отображения облика удаленных объектов, облаков и самого солнца. Мы изучим программные методы анимации облачного покрова, а также - достаточно неуместный здесь - эффект рассеяния света на объективе, обычно наблюдаемый во внеинтерьерной фотографии. Чтобы наполнить ландшафт, мы остановимся на методах изображения различных типов растительности. По мере знакомства с методами интерактивной визуализации всех видов флоры мы обсудим приемы отображения каждого из них, начиная с обычных тра- винок и заканчивая большими деревьями. Затем мы покинем сушу и окажемся в море, где будем строить шейдер океанской воды, окружающей ландшафт нашего острова. Наше изучение рендеринга ЗО-ландшафта в реальном времени мы закончим финаль- ной демонстрацией всего, что изучили на протяжении книги. Этот готовый ландшафтный движок позволит вам и дальше изучать интересующие вас темы, играя роль надежной основы в любых играх или приложениях, которые вы захотите создать. Дополнительные материалы Книга не имеет завершающей последней главы. Чтобы помочь вам в последующем развитии вашей программы, мы предлагаем несколько приложений, в которых сведены полезные руководства по программированию и рекомендуемые для чтения материалы. Они призваны служить удобным справочником, в котором вы сможете найти разнообраз- ные инструкции вершинных и пиксельных шейдеров DirectX 9.0 и отыскать описание новых приемов работы для дальнейшего изучения. В приложения также включены полный перечень содержимого компакт-диска и простые инструкции, поясняющие, как для упрощения работы содержимое CD-ROM можно установить на компьютер.
ВВЕДЕНИЕ 11 Несколько слов о стиле в программировании Говоря об этой книге, можно с полной уверенностью сказать, что в ней найдется несколько проектных решений или вариантов написания кода, которые вызовут у вас абсо- лютную неприязнь. Здесь мы представляем первый закон Снука (Snook) о программиро- вании игр: «Для каждого программиста найдется равнопротивоположный ему програм- мист, который ненавидит читать код первого программиста». Личный стиль в написании кода - он и есть личный стиль. Многие команды программистов теряли недели времени разработки на споры о наборе правил при программировании проекта. В центре большин- ства этих дискуссий были соглашения об именах, правила расстановки скобок и отступов строк. На самом деле описанию единых правил кодирования в команде были посвящены целые книги. Нам же достаточно сказать, что в том объеме кода, который содержится в этой книге, что-то обязательно будет вас раздражать. Что делать? Изменить код. Как уже говорилось, мы настоятельно рекомендуем вам кодировать свой движок по мере чтения книги. Лучшего способа изучить представленные в ней идеи, чем зако- дировать их самим, просто не существует. Кроме того, это даст вам возможность написать движок в своем собственном стиле, - никакой код не будет полезнее или удобнее для вос- приятия, чем ваш лично. Поэтому, хотя вам и придется, знакомясь с идеями книги, смириться со стилем кода, использованным в тексте и на компакт-диске, вы можете сво- бодно переписать этот код по своему вкусу. Литература [Duchaineau] Duchaineau, М., М. Wolinski, D. Sigeti, М. Miller, С. Aldrich, and М. Mineev-Weinstein. «ROAMing Terrain: Real-time Optimally Adapting Meshes» (работа доступна по адресу www.llnl.gov/graphics/ROAM). [Snook] Snook, G. «Simplified Terrain Using Interlocking Tiles» Game Programming Gems 2. Charles River Media, Inc., 2001. [Ulrich] Ulrich, Thatcher. «Chunked LOD» (работа доступна по адресу http://tulrich.com/ geekstuff/ chunklod.html).
Благодарности Моя особая благодарность - ребятам из Bungie за их помощь, советы и выдержку при написании книги, а также Брайану Харви (Brian Harvey) из NVIDIA за дополнительные рекомендации и поддержку.
Часть I Основы трехмерной графики Приступая к созданию движка 3 D-графики с чистого листа, мы просто обязаны начать с подробного введения в предметную область. Первую часть книги мы посвятим новым возможностям DirectX 9.0 и детальному изучению библиотеки DirectX Sample Framework, поставляемой с SDK. Пытаясь упростить свою задачу, мы будем строить наш движок поверх тех классов, которые в готовом виде предложены корпорацией Microsoft. Кроме того, мы обратимся к библиотеке Direct3D Extension Library (D3DX), являющейся частью DirectX 9.0 SDK. Эта полезная библиотека удовлетворит наши основные потребности в математических функциях трехмерной графики, а также предоставит удобные методы для загрузки и обслуживания ресурсов нашей игры. Изучая DirectX 9.0, мы подробно остановимся на языке высокого уровня High Level Shader Language (HLSL), предназначенном для написания шейдеров. Этот язык разработки напоминает С и позволяет создавать пиксельные и вершинные шейдеры, не прибегая к низ- коуровневому кодированию на ассемблере. Его появление стало большим достижением в программировании 3 D-шейдеров для современных аппаратных средств компьютерной графики. В основу этой книги положены методы на базе HLSL, а не ассемблера, который проигрывает ему в плане простоты применения и ясности исходного кода. Читателям, стремящимся к освоению языков низкого уровня, HLSL станет неплохим помощником в обучении. HLSL-компилятор с интерфейсом командной строки, который поставляется вместе с DirectX SDK (f хс . ехе), способен переводить программы на языке HLSL в набор' файлов ассемблера, для этого служит опция командной строки /Fc. Кодируя на HLSL и изучая порождаемый ассемблерный код, вы сможете начать изучение языков низкого уровня, в чем вам поможет и документация к системе DirectX SDK. Перио- дически переходя на ассемблер, вы получите прекрасную возможность убедиться в эффективности HLSL-кода, который нам предстоит написать. Работа с библиотеками D3DX и DirectX Sample Framework позволит нам быстро собрать и запустить наш движок. Впрочем, хотя применяемые нами библиотеки подходят для разработки готовых продуктов, они создавались как универсальные инструменты,
14 которые в определенных условиях способны жертвовать своей производительностью для достижения желаемой гибкости ваших решений. Реализуя конечный продукт на базе ланд- шафтного движка, мы, вероятно, обнаружим, что, зная семантику приложения и характерные особенности платформы, нетрудно «сгладить острые углы» или достичь более высокой производительности, заменив компоненты D3DX написанным вручную кодом. Чтобы учесть такую возможность, мы выстроим поверх D3DX свою собственную библиотеку, реализующую наш оригинальный интерфейс программирования. Если позд- нее мы обнаружим, что существующие библиотеки от Microsoft не отвечают нашим потребностям, подобный прием позволит создать собственную внутреннюю реализацию отдельных функций, не прибегая к изменению интерфейсов высокого уровня. Помимо кода, построенного поверх библиотек D3D Sample Framework и D3DX, мы создадим собственную библиотеку служебных функций низкого уровня, а также клас- сов, которые нам могут понадобиться. Эти важнейшие библиотеки будут содержать базо- вые интерфейсы для работы с числовыми значениями, данными с плавающей запятой и выделения памяти. Помимо этого, мы создадим ряд классов для отладки и профилиро- вания, что существенно облегчит процесс написания кода. При соблюдении «правил хоро- шего тона» в процессе кодирования эти классы помогут обнаруживать любые ошибки прежде, чем их наличие станет серьезной проблемой. 51 Эти ФУНК«ИИ и классы мы назовем компонентами библиотеки ядра ------(Core Library Components); найти их вы сможете на прилагаемом к книге компакт-диске. Процесс их создания самоочевиден и неплохо прокомментирован в исход- ном тексте. Поэтому в самой книге мы не будем подробно рассматривать практику их использования. Это позволит уделить больше времени главной задаче - построению качественного движка ландшафтного синтеза. Встретив подобный класс в тексте и желая лучше понять, как он работает, вы можете обратиться к соответствующим файлам исход- ного кода. В приложении А «Служебные классы Gaia» вы найдете обзор ряда самых востребованных служебных классов, а в приложении В «Секреты плавающей запятой» - рассказ об операциях с вещественными числами, проиллюстрированный примерами фрагментов игрового движка. По завершении этой части мы будем располагать всем необходимым для построения первой демонстрационной программы. Этой программой станет утилита просмотра моде- лей, которая будет способна загружать модели, представленные в широко распростра- ненном формате файлов Direct3D X (*.Х), накладывать текстуры и запускать анимации. Кроме того, мы научим ее загружать и показывать файлы эффектов D3DX (*.fx), в которых будут содержаться HLSL-шейдеры нашей собственной разработки.
Глава 1 DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ Если прежде вы никогда не занимались программированием 3 D-графики на плат- форме Microsoft Windows, то должны знать, что DirectX - это высокопроизводительная библиотека низкого уровня, предоставляющая интерфейс прикладного программирова- ния (API) для работы с устройствами мультимедиа. В ее состав входят компоненты, пред- назначенные для, прямо го доступа к основным элементам оборудования ПК. Решая поставленную задачу, мы будем работать главным образом с одним компонентом - Di- rectX Graphics. Точнее говоря, нам предстоит иметь дело с набором 3 D-функций DirectX Graphics, известным как Direct3D. Есть и другие интерфейсы DirectX, нацеленные на взаимодействие с пользователем, работу со звуком и обеспечение сетевых соединений, но мы использовать их не будем. Дальнейшее чтение потребует от вас знания основных идей DirectX. Обзор этой биб- лиотеки и необходимых нам компонентов приведен в самой книге, однако для более подробного изучения DirectX и программирования ЗО-графики в целом вам, вероятно, по- надобится документация, прилагаемая к пакету DirectX 9.0 SDK. Установив его, вы сможете обращаться к документации через главное меню Windows. Настройка Visual Studio.NET Если при инсталляции DirectX 9.0 SDK у вас уже установлена среда Microsoft Visual Studio, то результаты более ранней установки будут автоматически обновлены, после чего вам будет предоставлена возможность работать с библиотечными и заголовочными фай- лами SDK. Кроме того, у пользователей Visual Studio.NET появятся дополнительные сред- ства отладки DirectX, которые будут добавлены к обозревателю проекта .NET. В случае работы с другими компиляторами или установки Microsoft Visual Studio после инсталля- ции DirectX SDK путь к папкам с подключаемыми и библиотечными файлами DirectX нужно задать вручную, с тем чтобы программы с интерфейсом DirectX могли успешно проходить компиляцию и компоновку.
16 Глава 1 Те, кто используют компилятор, отличный от Microsoft Visual Studio.Net, должны знать, что библиотека Direct3D Extension Library (D3DX) поддерживает инструкции из набора Intel Streaming SIMD Extensions (SSE) только при компиляции программ в среде Microsoft Visual Studio.Net (или, точнее говоря, Microsoft Visual C++® 7.0 и выше). Это ограничение связано с поддержкой команд выделения памяти с выравниванием на грани- цу параграфа, которые неизвестны более ранним версиям Visual C++, если в них не уста- новлен особый модуль (processor pack). Такое выравнивание просто необходимо многим инструкциям доступа памяти в SSE, поэтому D3DX блокирует применение самих инструкций, если поддержка выделения памяти с выравниванием не гарантирована ком- пилятором. Проверить же наличие специального модуля никак нельзя, поэтому код биб- лиотеки D3DX, в котором используются инструкции SSE, будет недоступен, если не уста- новлена директива препроцессора, сигнализирующая о работе с компилятором Microsoft Visual C++ версии 7.0 или выше. Работая с компилятором другой фирмы или используя более раннюю версию Microsoft Visual C++, вы можете установить эту директиву самостоятельно, тем самым разрешив поддержку SSE в библиотеке D3DX. Однако при этом вы должны быть уверены в том, что ваш компилятор поддерживает выделение памяти с 16-байтным выравнива- нием, для чего служит нестандартный оператор_decl spec (align (16)), используемый в D3DX. Если вы сомневаетесь в поддержке такой возможности, обратитесь к документа- ции компилятора. Убедившись, что все в порядке, вы можете внести в make-файл или файл настроек своего проекта следующую директиву, имитирующую наличие компиля- тора Visual C++ 7.0 и разрешающую поддержку Intel SSE в библиотеке D3DX. #define _MSC_VER 1300 // имитировать наличие VC 7.0 Дополнительные файлы SDK находятся в двух основных папках - Include и Lib. В них содержатся файлы заголовков, необходимые для компиляции программ с DirectX, и файлы библиотек, которые нужны для их окончательной сборки. Обе папки можно найти в каталоге установки DirectX SDK. Если SDK был по умолчанию установлен в с: \dxsdk, искомыми папками станут с: \DXSDK\Include и с .• \DXSDK\Lib. Настройка компилятора и редактора связей на обращение к ним заключается в добавлении путей к этим папкам в список каталогов, где компилятор осуществляет поиск необходимых файлов. Более подробно об этом должно быть сказано в документации к вашему компилятору. Обе папки рекомендуется ставить на первое место в списке. Если вы пользуетесь Microsoft Visual Studio, то адресованное вам руко- водство к действию можете прочитать в файле помощи DirectX SDK под заголовком «Compil- ing DirectX Samples and Other DirectX Applications». Информация о подготовке DirectX к работе с вашей операционной системой и компилятором содержится и в HTML-файле dxreadme. htm, который находится в корневой папке SDK.
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ 17 Приложения Direct3D Sample Framework Основой всех прикладных программ DirectX SDK является ряд простых классов, разра- ботанных корпорацией Microsoft и выполняющих служебные функции. Эти классы не всегда предназначены для внедрения лишь в игровые продукты, поскольку в них заключен базовый набор функций, необходимых почти всем программам. Они выполняют рутинные процедуры настройки DirectX, включая перечисление видеоустройств, распозна- вание режимов монитора и возможностей каждого из режимов. Кроме того, эти классы помогают взаимодействовать с операционной системой Windows, освобождая нас от про- блем, связанных с обработкой сообщений и плавным переходом от оконного режима работы if полноэкранному. Не будем строить предположений о том, насколько ценным и эффектив- ным средством создания реальных продуктов является библиотека Sample Framework, но посоветуем прибегать к помощи тех ее элементов, которые будем использовать сами. Наш движок будет обращаться к средствам Sample Framework, когда нам потребуется создать простейшее приложение DirectX, выполнить ряд операций с файлами и произ- вести рендеринг. Копии отдельных файлов с текстом приложения D3D мы поместили в папки, выделенные для исходного кода ядра. Это позволит нам гарантировать успешную компиляцию и компоновку ядра, даже если на машине будет установлена одна из будущих версий DirectX SDK. Впрочем, по мере выхода новых версий SDK файлы Sample Frame- work, которые скопированы в папки с исходным кодом, следует обновлять, что позволит вам пользоваться всеми преимуществами нововведений. Любое приложение Sample Framework содержит файлы, попарно перечисленные в таблице 1.1с указанием их функционального назначения. Эти файлы находятся в папке, где установлен пакет SDK, а именно - в подкаталоге Samples\c+ + \common. Таблица 1.1. Файлы, поставляемые в составе Microsoft DirectX Samples Framework Файлы (.Ии.срр) Назначение D3DApp Содержат класс CD3DApplication, ответственный за формирование общей структуры приложения Direct3D D3DEnumeration Содержат класс CD3DEnumeration, объект которого опрашивает резидентную видеоаппаратуру и выдает перечень режимов работы экрана и возможностей монитора D3DFile Набор классов для загрузки и отображения объектов CD3DMesh. В нашем прило- жении эти файлы использоваться не будут D3DFont Содержат класс CD3DFont, который создан для упрощения вывода 20-текста на 30-сцене. В нашем ядре будет использован лишь для отладки D3DSettings Содержат классы CD3DSettings и CD3DSettingsDialog. В них реализо- ван метод распознавания текущих настроек экрана приложения и отображения окна диалога для смены режимов экрана в примерах программ DirectX
18 Глава 1 Таблица 1.1. Файлы. поставляемые в составе Microsoft DirectX Samples Framework (Продолжение) Файлы (.h и .срр) Назначение____________________________________________________________ D3DUtil Набор служебных функций, которые используются в примерах Direct3D, включает класс камеры CD3Dcamera, а также устройство пользовательского ввода CD3DArcBall DXUtil Здесь находятся полезные утилиты DirectX, в том числе функции работы со строками, функции доступа к реестру, а также простой класс массива переменного размера CArrayList Как видно из таблицы 1,1, мы не планируем использовать все файлы D3D. К примеру, нам будут не нужны функции загрузки меша (mesh), описанные в файлах D3DFile.h и D3DFile. срр. Чтобы загружать свои данные, мы воспользуемся нестандартным рас- ширением файлового формата .х, написанным с применением D3DX, речь о котором пойдет позднее. В этой схеме загрузки мы будем опираться на собственные классы, реали- зующие сохранение и отображение мешей, так что объекты из D3DFile нам не понадо- бятся. Кроме того, средства вывода текстов, предоставленные классом CD3Dfont, будут использоваться нами лишь для отображения отладочной информации. Если бы наше при- ложение реально нуждалось в выводе текста, мы могли бы описать собственный высокоэф- фективный метод его отображения. Особенно это справедливо в отношении возможностей CD3Dfont - класса, который совершенно бесполезен в контексте нашего приложения. ( В своем стремлении к простоте мы будем использовать то окно диалога, -------- которое служит для выбора режимов работы монитора во всех примерах про- грамм, построенных на основе D3D. В реальном продукте скорее всего мы бы создали свой собственный интерфейс подобного рода, однако при изучении проблем рендеринга ландшафта такой особый интерфейс нам не нужен. В итоге на этом шаге разработки ядра мы будем обращаться к файлам D3DSettings. Однако применение класса CD3DSet- tingsDialog не означает того, что мы должны включить в проект и файл D3DRes.h, который содержит описания ресурсов диалогового окна, и копировать шаблон диалога в файл ресурсов нашего приложения. Все это уже сделано в том примере, который содержится на прилагаемом компакт-диске. Самым важным среди используемых классов станет CD3DApplication. Он является своего рода опорой D3D Sample Framework. Если вы просмотрели примеры текстов про- грамм, поставляемых с DirectX SDK, то отчасти уже познакомились с этим классом. В нем реализовано все, что необходимо работающей программе, - от создания главного окна и управления конвейером сообщений до запроса режимов работы видеокарты иопределения возможностей монитора при помощи класса CD3DEnwneration. В этом классе содержится и основной цикл приложения, который отвечает за чтение поступающих от Windows сообщений и вызов собственных методов обновления и рендеринга сцены.
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ 19 Класс CD3DApplication столь полезен для нас потому, что мы имеем право его рас- ширить. Отдельные функции-члены этого класса описаны как виртуальные, что позволяет нам породить от CD3DApplication собственный класс и перегрузить эти функции для придания им нужной функциональности. В этом ключе построены почти, если не все при- меры с применением D3D, где виртуальные члены CD3DApplication перегружаются для демонстрации пользователям важнейших аспектов решения конкретных задач. Наше при- ложение не будет здесь исключением, и по прошествии нескольких глав мы создадим свой экземпляр CD3DApplication, в который и поместим собственное графическое ядро. Хотя напрямую мы можем работать лишь с интерфейсом CD3DApplication, есть еще несколько классов, которые используются нами неявно. Изучая их функциональное назначение, вы гораздо лучше поймете организацию DirectX и пройдете прекрасную школу овладения DirectX SDK в целом. Самый заметный из них - уже названный класс CD3DEnumeration. Тем, кто впервые приступает к работе с DirectX, скажем, что этот класс предоставляет ряд функций опроса резидентных элементов видеосистемы и формирует список поддерживаемых монитором режимов. Последнее является ключевым моментом настройки графического окружения DirectX. Тщательный анализ возможностей этого класса поможет детально изучить процесс создания упомянутого списка режимов. Математическая библиотека D3DX Как мы уже отмечали, необходимые для решения задачи математические функции мы позаимствуем из библиотеки D3DX. Ее применение требует знания основных разделов «пространственной» математики. К ним относятся действия над векторами, матрицами, вращение кватернионов. В работе с углами и векторами полезными будут и базовые знания тригонометрии. Если для разработки реальной игры вам может и в самом деле потребоваться весьма сложная математика, то для создания базового ядра достаточно понимать лишь основные идеи этой науки. Ниже нам предстоит краткий экскурс в основы тригонометрии, вектора, матрицы и вращения с использованием кватернионов. В документации к DirectX SDK есть собст- венное введение, которое посвящено тем же вопросам и адресовано читателям, желающим получить более основательную подготовку. Кроме того, перечень книг, перио- дических изданий и ссылок на Web-ресурсы по основам «пространственной» математики вынесен в приложение D «Рекомендуемая литература». Всякому, кто начинает заниматься вопросами компьютерной графики, может пока- заться, что математику в таком объеме изучить попросту невозможно. И здесь не помогут слова о том, что образованные в области математики люди потратили немало времени, пытаясь облечь эти идеи в удобную для восприятия форму. Академично настроенные авторы нередко используют формулы там, где надо просто изложить сущность самой
20 Глава 1 идеи. Но тех, кто не привык к такой записи, подобное может только запутать. Каждый раз, видя перед собой формулу, читатель должен остановиться и препарировать ее лишь для того, чтобы двигаться дальше. К примеру, расчет среднего значения набора чисел можно выразить так: 1 " т = — V Ц для п исходных значений V. П ,=! А если просто описать сам процесс? «Сложите все данные числа и разделите результат на общее их количество» На протяжении этой книги мы будем не раз приводить формальные описания основ- ных геометрических операций и операций закраски. Стремясь к более ясному изложению материала, мы по возможности будем дробить эти формулы на более короткие уравнения. Система координат Direct3D Прежде чем приступать к изучению алгебры и геометрии пространства, давайте прида- дим единый вид всему, что в нем находится. Иными словами, опишем ЗГ?-пространство, которое будем использовать с тем, чтобы различные математические конструкции имели у нас общую, согласованную основу. Воспользуемся для этого декартовой системой коорди- нат. Декартовы системы координат - нечто иное, как система обозначений для представления пространства с помощью набора осей. Оси сходятся в одной точке - начале координат, что позволяет задать на них метрику, которая может служить для указания положения точек в данном пространстве. Все это звучит гораздо сложнее, чем выглядит на самом деле. Системы координат часто находят свое применение, хотя мы об этом, возможно, и не догадываемся. Всякий раз при описании положения объекта по горизонтали и вертикали как набора значений х и у мы пользуемся системой координат. Закрашивая пикселы на экране или помещая в контекст устройства Windows текст с помощью функции TextOut, мы при- бегаем к двухмерной декартовой системе координат. В том и в другом случае точка опреде- ляется как расстояние вдоль оси х и оси у от начала координат. На рис. 1.1 показан пример двухмерной координатной системы и точки, которая задана значениями по осям х и у. Переход к трем измерениям можно произвести по-разному. При заданной системе координат с осями х иу есть целых два способа построить третью ось z. Используя сис- тему координат на рис. 1.1, где каждая стрелка направлена в сторону положительной полуоси, ось z можно провести так, что ее стрелка будет указывать либо на вас, либо в противоположном от вас направлении. В итоге мы получили правую и левую трех- мерную систему координат.
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ 21 з- 2.7, 2.4 Л 2 - и о 0.4. 1.6 1 ' 1.3, 0 9 0 -I-----1-------I-------I------- 0 1-234 ОСЬ X Рис. 1.1. Простая двухмерная декартова система координат Идея правой или левой ориентации векторов не сильно упрощает точное определение направления оси z. Ее введение должно было помочь запомнить расположение осей при помощи пальцев рук. Определенным образом сложив пальцы левой руки, можно предста- вить левую координатную систему. Сложив пальцы правой руки, можно представить правую систему координат. Однако в разных источниках показаны разные положения рук и разные положения сложенных пальцев. На рис. 1.2 приведены распространенные поло- жения рук при демонстрации каждой координатной' системы. Направление оси у в этом примере показывает большой палец. Направление кратчайшего поворота оси z до совме- щения с х совпадает с направлением, в котором согнуты остальные пальцы. Более простой способ запомнить ориентацию векторов - представить себе двух- мерную систему координат на рис. 1.1, где положительное направление оси х - это направление вправо, а положительное направление оси у - это направление вверх. Теперь представьте правый вариант оси z, исходящей прямо со страниц книги. Правоориентиро- ванная ось z указывает непосредственно в вашу сторону, и все, что движется в положи- тельном направлении оси z, «смотрит» прямо на вас. Левоориентированная ось z указы- вает внутрь страницы или от вас. В этом смысле все, что движется в положительном направлении такой оси z, оставляет вас позади. Конечно, это еще более странно, чем при- бегать к жестикуляции руками, но этот способ работает.
22 Глава 1 Рис. 1.2. Положения левой и правой руки, которые позволяют запомнить отличия лево- и право- ориентированной трехмерной системы координат Чтобы сильнее сбить с толку читателей, авторы самых далеких от практики книг приме- няют правую систему координат, хотя в графических API-интерфейсах, среди которых и DirectX, используется левоориентированная система. Это связано с тем, что на бумаге трехмерная система координат выглядит более понятно, если ось z в ней направлена в сторо- ну человека. На практике положительные значения z удобнее отсчитывать в направлении от ЗВ-камеры, а значит, по мере увеличения z-координаты объекта он все сильнее отодви- гается от наблюдателя. По этой причине графические API часто «переворачивают» ось z так, чтобы направить ее внутрь экрана и использовать левую систему координат. В нашей книге мы будем пользоваться левой координатной системой, ничем не отличающейся от той, что применяется в DirectX. Точки и векторы в D3DX Описав систему координат, можно приступить к изучению двух основных примити- вов, из которых состоят все ЗВ-объскты библиотеки D3DX: точек и векторов. В D3DX эти понятия синонимичны и представлены экземплярами D3DXVECTOR. Чтобы лучше понять их возможности, рассмотрим сами точки и векторы, а также ряд ключевых идей,, стоящих за операциями над ними в ЗВ-пространстве. В трехмерной системе координат для описания точки в ЗВ-пространстве служит рас- стояние от нее до начала координат, которое отсчитывается вдоль осей х, у и z. Это значит, что положение любой точки можно представить тройкой чисел, именуемых х-, у- и z-ko- ординатами точки. При записи формул точки часто обозначают заглавными буквами,
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ 22 которые выделены курсивом: Р, Q. Отдельные координаты точек обозначают при помощи индексов: Рх, Ру или Pz. Вектор во многом аналогичен точке, однако он отражает направление, в котором некая точка видна из начала координат, и, как говорят, имеет величину, равную расстоя- нию от начала координат до этой точки. Хотя способ представления точек и векторов пол- ностью идентичен (а это три значения, равных расстоянию до точки вдоль осей х, у, z), их назначение различно. Так, на рис. 1.3 изображены точка и вектор, равные Друг другу по каждой оси. Точка служит для указания конкретного положения в пространстве, вектор обозначает направление на эту точку. Рис. 1.3. Пример точки и вектора с равными значениями х- и у-координат Впрочем, хотя точки и векторы в чем-то отличаются друг от друга, их обработка построена на одних принципах, а формат в точности совпадает. Фактически можно говорить о том, что точка является концом вектора, исходящего из начала координат, и что они, по сути, равны. В силу этих причин в отдельных текстовых и графических API-интерфейсах (DirectX - не исключение) понятия точек и векторов взаимно заменяют друг друга. Нормирование векторов Напомним, что вектор можно считать направлением, под которым точка видна из начала координат. Это направление характеризуется расстоянием, равным длине век- тора. Длину еще называют величиной вектора и в обозначениях часто пишут под знаком абсолютной величины, в который заключают название самого вектора. Длина, или величина, вектора вычисляется по формуле, приведенной в уравнении 1.1. М=^ + ^ + ^2) (1.1)
24 Глава 1 Попросту говоря, это выражение означает сложение квадратов всех компонентов век- тора и извлечение квадратного корня из суммы. Вектор нормирован, если его величина равна единице. В этом случае сумма квадратов всех компонентов должна быть равна 1, так как квадратный корень из 1 тоже есть 1. Нормированные векторы часто именуют единичными, так как их протяженность составляет одну единицу длины. Чтобы нормировать вектор, нужно привести его длину к 1. Для этого достаточно найти величину вектора по уравнению 1.1 и разделить на это значе- ние каждый из компонентов. В итоге длина вектора станет равна единице. Удобным сред- ством нормировать двух- и трехмерные векторы в D3DX служат, соответственно, функции D3DXVec2Normalize и D3DXVec3Normalize. Скалярное произведение Скалярное произведение двух- и трехмерных векторов в D3DX реализуется функ- циями D3DXVec2Dot и D3DXVec3Dot. Само по себе оно является важной операцией, которую мы будем постоянно использовать в графическом ядре, вершинных и пиксельных шейдерах. Поэтому уделим этой операции пару минут и выясним, что же такое скалярное произведение и чем оно для нас так интересно. В основе скалярного произведения векторов лежит взаимосвязь их направлений с учетом длины обоих. Формально скалярным произведением двух векторов является косинус угла между ними, умноженный на величину каждого вектора. Вертикальные линии вокруг векторов обозначают величину последних (значение, найденное по уравне- нию 1.1). Выраженный в радианах угол между векторами обозначим греческой буквой «альфа» (а); см. уравнение 1.2. Р‘О = И1Ф““ (1.2) Однако реально значение скалярного произведения можно найти и не вычисляя коси- нус. Для этого мы перемножим соответствующие компоненты векторов и сложим результат. Альтернативный способ расчета скалярного произведения представлен уравнением 1.3. Р. Q= *Qx} + (pv *Qy) + (P/*Q/) (1-3) Отсюда можно вывести формулу для отыскания угла между векторами. Сначала объе- диним оба предыдущих уравнения, после чего выразим значение угла в левой части. Ход вычислений проиллюстрируем уравнениями 1.4- 1.6.
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ 25 H№osa = (Р, * Qx) + (Л- * Qy) + (Рг * Qz) (px*Qx) + (p} *Qy) + (P,*Qx) НМ (1.4) (1-5) (1-6) Пользуясь уравнением 1.6, можно найти выраженный в радианах угол между любой парой векторов. Впрочем, для его отыскания далеко не всегда нужно проделывать такую работу. Если оба вектора нормализованы, их величина равна 1. Тогда деление в правой части уравнения 1.6 становится избыточным, а в формуле остаются лишь несколько умно- жений, сложений и этот неприятный арккосинус. Избежать его вычисления во многих случаях позволяет замечательное свойство, которым наделена функция косинуса. Как показано на рис. 1.4, ее кривая в интервале от 0 до л пробегает значения от 1 до -1. Пользуясь этим, кое-что о самом угле можно узнать и не выполняя триго- нометрических расчетов. Рис. 1.4. График функции косинуса в интервале от 0 до 7Г
26 Глава 1 Если два вектора параллельны и направлены в одну сторону, их скалярное произведе- ние равно 1. В самом деле, угол между ними равен 0, а косинус нуля есть 1. По мере того как скалярное произведение двух векторов приближается к 1, угол между ними стремится к нулю, и в пределе векторы совпадают. Из рис. 1.4 видно и другое: если угол между двумя векторами составляет тг/2 (90 граду- сов), их скалярное произведение является нулевым. Это опять-таки следует из того, что ска- лярное произведение равно косинусу угла, а косинус тг/2 - это 0. Кроме того, заметим, что при значении утла, превышающем тг/2, значение косинуса, а значит, и скалярного произведе- ния отрицательно. Если два вектора направлены в противоположные стороны, их скалярное произведение равно косинусу угла л, то есть -1. Все сказанное сведено нами в таблицу 1.2. Таблица 1.2. Свойства скалярного произведения векторов Произведение Угол между векторами 1 Угол между векторами равен нулю 0 Векторы перпендикулярны друг другу -1 Угол между векторами равен л (180 градусов) Последнее свойство скалярного произведения - возможность построения проекции одного вектора на другой. Если один из двух векторов - единичный, то их скалярное произве- дение численно равно длине проекции второго вектора на него. Ситуацию поясняет рис. 1.5. Рис. 1.5. Результат скалярного произведения вектора общего вида А на единичный В - это длина проекции А на В Проекция тоже связана с функцией косинуса. Сам косинус - это нечто иное, как длина смежной к углу стороны прямоугольного треугольника1. Вновь обращаясь к рис. 1.5, можно увидеть, что оба вектора действительно образуют прямоугольный треугольник, третья сторона которого совпадает с направлением проекции (перпендикулярной к единичному 1 Взятая в отношении к гипотенузе. - Примеч. пер.
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ вектору). На стороне, смежной к углу между обоими векторами, лежит единичный вектор, что и позволяет интерпретировать косинус утла как длину стороны треугольника. В чем польза таких построений? В том, что проецирование векторов общего вида на единичные не отличается от вращения точки в 3 D-пространстве. Чтобы, имея точку в мировом пространстве и набор единичных векторов - систему координат, в которой про- изойдет вращение точки, - получить требуемый результат, достаточно спроецировать данную точку на каждую из осей. Построив проекцию точки на единичный вектор, можно найти расстояние между точкой и плоскостью. При заданном единичном векторе, который располагается по нормали (перпендикулярно) к последней, расчет расстояния сводится к построению проекции точки на нормаль к плоскости. Векторное произведение Библиотека D3DX содержит функции расчета не только скалярных, но и векторных произведений (D3DXVec2Cross и D3DXVec3Cross), которые мы тоже могли бы вызывать, не задумываясь о том, как они работают; тем не менее для выполнения ряда операций, которыми мы займемся позднее, знать сущность векторных произведений просто необхо- димо. Поэтому давайте остановимся на этой проблеме и внимательно ее изучим. В отличие от скалярного аналога, векторное произведение двух векторов не дает един- ственного скалярного результата. Вместо него результатом операции становится третий вектор. Он перпендикулярен плоскости, образуемой первыми двумя векторами, а его величина численно равна площади параллелограмма, построенного на исходных век- торах. Вычислить векторное произведение можно так, как показано в уравнении 1.7. PxQ=(/v?z-W PA-P^’ PxQv-PyQx) (1-7) Однако плоскость, в которой лежат векторы Р и Q, делит пространство надвое. В какую же сторону будет направлен перпендикуляр? Результат операции зависит от порядка указа- ния векторов. Векторное произведение подчиняется правилу правой руки, а потому расчет векторного произведения Р и Q дает в результате перпендикуляр с правой ориентацией. Вспомните о правых и левых системах координат на рис. 1.2. Для двух координатных век- торов X и Y третий вектор Z можно определить по правилу как левой, так и правой руки. Расчет векторного произведения оси х на ось у даст правоориентированную ось г. Сменив порядок осей х и у на обратный, получим ось z с левой ориентацией.
28 Глава 1 Матрицы в D3DX Матрицы - это неотъемлемая часть любой системы ЗО-графики. С их помощью можно свести воедино множество операций над векторами. Матрицы дают возможность кратко записывать целые группы уравнений; к тому же их можно объединять с другими матрица- ми и еще больше увеличивать число уравнений, которые они представляют. Подробный рассказ о матрицах выходит за рамки этой книги. Здесь мы расскажем о том, как использо- вать матрицы для 3 D-преобразований. За более подробной информацией о матрицах обра- титесь к источникам, указанным в приложении D, или к документации по DirectX SDK. Матрица - это заполненная числами сетка, имеющая два измерения. В общем случае ее размер может быть произвольным, однако в компьютерной графике есть всего несколько типичных размеров матриц. Нумерация значений в матрице осуществляется согласно тому, в какой строке и каком столбце находятся эти значения. В большинстве книг элементы матриц обозначаются парой строка - столбец. Общий вид матрицы представлен на рис. 1.6. т11 т12 L т21 т22 L м м О м тм тю L mhw Рис. 1.6. Общий вид W х Н-матрицы Матрицы позволяют легко и просто манипулировать точками ЗО-пространства. Расположив элементы матриц определенным образом, можно как угодно перемещать, поворачивать или масштабировать модель, построенную из точек. Матрицы представ- ляют собой компактное описание координатных систем, поэтому их можно использовать для переноса объектов в мировом пространстве. Эта операция потребует лишь перемеще- ния точки отсчета локальной системы координат объекта. Действие матрицы на точки нашей модели может заключаться в осуществлении серии аффинных преобразований (вращения, масштабирования и переноса), а значит, и в погружении модели в ту систему координат, которую задает эта матрица. Прежде чем двигаться дальше, нам нужно определиться с тем, какой способ записи матриц будет использован в книге. На деле есть два способа представить матрицу на бумаге, и те или иные авторы предпочитают один вариант другому. В нашей книге матрицы будут заполняться слева направо, именно так, как они хранятся в оперативной памяти. Наша запись отличается от той, что принята в ряде книг по программированию машинной графики, авторы которых изображают матрицы не так, как размещают их в памяти (строки и столбцы меняются при этом местами, то есть матрица транспонируется).
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ 29 Скажем, шаблон матриц преобразования 4 х 4 в книгах часто представлен сеткой из трех расположенных по столбцам координатных осей (вправо, вверх и вперед)2, за которыми следует последний столбец, содержащий смещение вдоль каждой оси. При такой форме записи матриц векторы также трактуются как столбцы. Right* Upx Forward* Тх~ X м = Righty UPy Forward у Л V = У Right* иР. Forward* Т Z Z 0 0 0 1 IV Это представление именуют разверткой матрицы по столбцам; и оно, пожалуй, является самым распространенным. Впрочем, это не самый эффективный порядок хране- ния матриц в оперативной памяти. Так, чтобы использовать все преимущества набора инструкций Intel SSE, гораздо удобнее хранить матрицы в транспонированном виде. Поэтому большинство математических библиотек, работающих в пространстве, заносят в оперативную память транспонированный вариант матриц, представленных на бумаге. В этом формате отдельные векторы интерпретируются как строки. Иногда этот формат называют разверткой матрицы по строкам. Right* Righty Right* o' м = . иР* Upy up. 0 1V1 — Forward* Forward* Forward* 0 V — у Z W Т X ТУ T* 1 В нашей книге мы будем поддерживать полное соответствие формата записи всех матриц в тексте их расположению в оперативной памяти. На протяжении всех следующих глав матрицы будут развертываться по строкам. Этот подход более интуитивен, даже несмотря на то что матрицы, представленные в других книгах, нужно мысленно транспо- нировать, чтобы привести их в соответствие с матрицами в нашем формате. Тот же формат, что и у нас, нашел свое применение во многих популярных книгах по компьютерной графи- ке, в том числе в книге Алана Уатта (Alan Watt) 3D Computer Graphics [Watt], а также в доку- ментации к Direct SDK, и это дает нам право говорить, что мы в этом не одиноки. Основной операцией над матрицами станет для нас умножение. Предваряя обсужде- ние полезных свойств самих матриц, продемонстрируем процесс умножения одной 2 Right - вправо, Up - вверх, Forward - вперед - Примеч пер.
30 Глава 1 -матрицы на другую. Для двух 4 х 4-матриц М и N результатом умножения станет матрица из уравнения 1.8. ’л в С D Е F G Н м I J К L м N О Р м * XI = N = abed е f g h I J к 1 m n op (Аа+ Ве + Ci + Dm} (Ba + Be + Gi + Нт} (la + Je+ Ki + Lm} (Ma + Ne+ Oi + Pm} (Ab+Bf+CJ+Dn} {Eb+FL vGj + Hn} (lb + Jf + Kj + Ln} (Mb+ Nf + Oj + Pn} (Ac + Bg+ Ck + Do} (Ec + Fg+ Gk+ Ho} (ic + Jg + Kk + Lo} (мс+Ng+Ok + Po} (Ad + Bh + Cl + Dp} (Ed+Fh+Gl+Hp} (ld + Jh+Kl+Lp} [Md+ Nh + Ol+ Pp} (1-8) Ее вид создает впечатление хаоса, однако все гораздо проще, чем кажется. Каждый I элемент произведения является результатом скалярного умножения строки матрицы М на столбец матрицы N. Так, элемент в левом нижнем углу матрицы-результата (столбец 1, строка 4) - это скалярное произведение строки номер 4 матрицы М на столбец номер 1 матрицы N. Чтобы произвести матричное преобразование точки (или вектора), мы будем считать их координаты элементами одномерной матрицы. Для отыскания новой точки можно исполь- зовать обычные правила матричного умножения. Пусть точка трактуется как матрица с одной строкой, тогда уравнение 1.9 позволит нам вычислить каждый компонент нового вектора. Р г = (Хс+ Yg+Zk + Wo) Р H. = (Xd + Yh +Z1 + Др) (1-9) Принято считать, что точка и вектор содержат три компонента: х, у и z. Преобразование вектора или точки при помощи матрицы 4x4 требует добавить четвертый компонент w.
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ 31 При этом проявляется одно интересное свойство. Как следует из уравнения 1.9, значение w управляет тем, насколько сильно последняя строка матрицы М влияет на полученный результат. Каждое из значений этой строки (т, п, о и р) умножается на w и прибавляется к значению результата. В этой строке содержится ответственная за перепое объекта транс- ляционная составляющая аффинных преобразований. Компонент w управляет тем, может ли заложенная в матрицу информация о переносе повлиять на результат преобразования. Дополнительный w-компонент точек принимают равным 1, что позволяет производить их перенос. Для векторов и-компонент часто уста- навливают в 0. Это дает возможность ограничить действие матрицы вращением и масшта- бированием вектора и запретить его перенос относительно начала координат. Векторы трактуются как направления на точку, а потому возможность их переноса привела бы к искажению значений. Это удобный трюк, которым мы воспользуемся при написании функций матричного преобразования точек и векторов. Перемножение матриц, которые представляют аффинные преобразования, ведет к объединению этих преобразований в порядке выполнения операций. Так, если матрица М представляет перенос на 3 единицы по оси х, а матрица N - поворот на 90 градусов вокруг оси у, то результат М * N будет представлять сдвиг вдоль оси х, за которым после- дует поворот вокруг оси у. Этот процесс часто называют конкатенацией матриц, и это одна из самых приятных вещей, ставших оправданием применения матриц в 3D-графике. Библиотека D3DX содержит множество классов для хранения матриц, а также функ- ций их создания и манипуляции с ними. Пояснения к каждой из них можно найти в доку- ментации к DirectX SDK. Одна из новых возможностей D3DX, введенных в DirectX 9.0, - матрицы с 16-байтным выравниванием: D3DXMATRIXA16. В них реализована поддержка выравнивания данных на границу 16 байт, что необходимо инструкциям набора SSE для обеспечения наибольшей эффективности их работы. Воспользоваться ею, как уже говори- лось, могут лишь приложения, которые скомпилированы в среде Visual C++ 7.0. В осталь- ных случаях D3DXMATRIXA16 является синонимом стандартного класса D3DXMATRIX и не обеспечивает никакого выравнивания. Вращение кватернионов Как говорится в документации, кватернион - это, по сути, четырехмерный вектор, значения компонентов которого служат для описания поворота против часовой стрелки вокруг данной оси. Все компоненты кватерниона q представлены в уравнении 1.10. Эти значения определяются самой осью вращения, а также поворотом вокруг нее против часо- вой стрелки, который обозначен греческой буквой «тета» (0).
32 Глава 1 q.x = sin( в/2) * axi q.y = sin( в/2) * axis.y q.z = sin( f?/2) * axis.z q.w = cos(0/2) (1.10) Интерес к структуре кватерниона вызван тем, что последний можно использовать как средство, которое лишено недостатков, присущих матрицам вращения, требует меньше памяти, не нуждается в интерполяции и не расположено к возникновению явле- ния, известного как блокировка вращения (gimbal lock). Этот эффект возникает, когда пара осей системы координат становятся коллинеарными. Такое может произойти при построе- нии матриц преобразования на базе дискретных эйлеровых углов (то есть раздельных поворотов относительно каждой из трех главных осей). Неправильное выполнение этой операции ведет к тому, что один из координатных векторов при вращении накладывается на другой вектор системы координат. Дальнейшие повороты вокруг свободной оси не дают результата, как будто ось «блокирована» и ее нельзя повернуть. В основе кватерниона лежит неэйлеров метод описания поворота вокруг данной оси в трехмерном пространстве. Он позволяет избегать возможного возникновения блокировок вращения. Впрочем, за это приходится платить ясностью построения, так как представить себе конструкцию кватерниона может лишь абсолютное меньшинство. Кватернион есть комплексное число, которое содержит как действительную, так и мнимую части и детальное толкование которого выходит за рамки книги. В приложении D вы сможете найти обширный перечень работ, где подробно обсуждаются вопросы вращения кватернионов, а также изло- жена алгебра этих чисел. Выигрыш в объеме памяти при работе с кватернионами очевиден. Кватернион пол- ностью описывает 3D-вращение четырьмя числами, тогда как матрица - девятью. Хотя для применения в конвейере DirectX кватернионы надо переводить в формат матриц, размер моделей с большим количеством необходимых для анимации поворотов можно существенно сократить, пользуясь кватернионами для представления этих данных. Так же как и матрицы, кватернионы допускают конкатенацию путем обычного умно- жения. Кроме того, хранение данных в виде кватернионов дает возможность осуществ- лять их интерполяцию. Этот метод - сферическая линейная интерполяция (SLERP, Spherical Linear Interpolation) - гораздо проще и с вычислительной точки зрения эффек- тивнее интерполяции матриц вращения. При анимации скелетных иерархий по ключевым кадрам (keyframe animation) кватернионное представление данных служит залогом опти- мальной производительности, достичь которой не мешает даже большое число операций интерполяции при расчете промежуточных поз скелета.
DIRECTX 9.0 И D3DX: ПЕРВЫЕ ШАГИ В библиотеке D3DX вы найдете кватернион - объект D3DXQuaternion, а также ряд функций, которые позволяют умножать, интерполировать кватернионы и выполнять с ними различные манипуляции. Там же есть функции преобразования матриц вращения в кватернионы и обратно. Документацию с описанием каждой из этих функций можно найти в SDK. Литература [Watt] Watt, А. 3D Computer Graphics, Second Edition. Addison-Wesley Publishers Ltd., 1993.
Глава 2 ОСНОВНЫЕ ЗО-ОБЪЕКТЫ Познакомившись с возможностями Direct3D Sample Framework и библиотеки Direct3D Extension Library, мы можем подробнее обсудить ряд элементов D3DX, которые, так или иначе, помогут нам в построении графического ядра. В этой главе мы поговорим об отдель- ных классах D3DX и объектах, которые они представляют и которыми мы будем повсе- местно пользоваться для загрузки, рендеринга и сохранения наших моделей мира. Помимо этого, мы остановимся на двух основных форматах файлов, применяемых в Direct3D, и объ- ектах для получения данных. Речь пойдет об объекте эффектов Direct3D и формате файлов .fx, а также Х-файлах Direct3D с расширением .х. Наконец, мы обратимся к созданию иерархий подобных объектов при ПОМОЩИ структур D3DXFRAME И D3DXMESHCONTAINER, которые служат для анимации скелетов и объектов с нетривиальной организацией. Возможность загрузки информации о моделях и шейдерах с диска - насущная потреб- ность, возникающая при построении всех систем ЗО-графики. Конечно, формат таких файлов можно создать самим, однако делать это совсем не обязательно - достаточной гибкостью обладают форматы файлов самой DirectX. Работая с этими файлами, мы сможем пользоваться набором средств, уже имеющихся в библиотеке. В него войдут программы экспорта в форматы самых популярных пакетов 3 D-моделирования и анима- ции, а также поставляемые с DirectX SDK инструменты для редактирования и просмотра файлов моделей (.х) и эффектов (.fx). Кроме того, Direct3D дает возможность расширять формат Х-файлов за счет наших собственных данных. Такое расширение позволит добиться желаемой гибкости при сохранении работоспособности Х-файлов в будущем. Основные объекты Direct3D Отображение объекта в 3 D-пространстве требует наличия трех вещей: самой модели, материала, которым описана ее поверхность, и, возможно, текстурной карты для нанесения материала на модель. Помимо методов освещения и закраски, это основные элементы, необходимые для рендеринга ЗО-моделей. Для представления каждого из них в библиотеке
ОСНОВНЫЕ 30-ОБЪЕКТЫ 35 D3DX есть ряд соответствующих объектов, а также множество функций для их загрузки, управления ими, а также их применения. Познакомиться с классами D3DX, которые служат для представления базовых эле- ментов, вам помогут материалы, поставляемые с DirectX SDK. В нашей книге мы прове- дем лишь краткий обзор этих классов. Более подробную информацию вы найдете в доку- ментации по SDK. D3DXMaterial - самый «лаконичный» из трех объектов. Фактически это структура, которая содержит свойства поверхности D3dmaterial9 (данные об оттенках света, отра- женного от разных объектов) и необязательное имя файла текстуры, наносимой на поверх- ность модели. Довольствуясь единственной ссылкой на текстуру, мы воплощаем слегка устаревший стиль реализации такого объекта, который, несмотря на это, играет роль струк- туры, где хранятся базовые параметры освещения нашей модели. Объект IDirect3DTexture9 - это интерфейс, дающий возможность манипулиро- вать ресурсами текстур и применять их в процессе рендеринга. Библиотека D3DX содержит ряд функций, делающих работу с объектами !Direct3DTexture9 значительно проще. Одна из таких функций, D3DXCreateTextureFromFile, способна импортировать текстуры из файлов самых разнообразных растровых форматов, включая .bmp, .dds, .dib, .jpg, .png и .tga. Помимо надежной работы с файлами, различные функции загрузки тек- стур в D3DX дают возможность менять размер изображений при импорте, а также глу- бину цвета, для чего служит целый ряд фильтров. Кроме того, библиотека D3DX содержит множество функций для работы с объемными 3 D-текстурами и кубическими картами окружающего пространства. Эти функции мы изучим позднее, когда перейдем к рас- смотрению сложных методов рендеринга. Из всех трех классов !D3DXMesh больше других заслуживает того, чтобы назвать его «рабочей лошадкой» библиотеки. В нем хранится геометрическая информация о самой моде- ли, а его поля описывают формат ее вершин. В D3DX есть несколько средств загрузки мешей из файлов собственного формата DirectX, известного как .Х-файлы. Базовый вариант .Х-фай- лов, основанный, как и структура D3DXMATERIAL, на принципе «один материал - одна тек- стура», тоже слегка устарел, однако допускает расширение со стороны пользователя. Этой расширяемостью мы и воспользуемся для сохранения в .Х-файлах и считывания из них наших собственных данных, которые потребуются ядру при дальнейшей его разработке. Изначально класс lD3DXMesh - это не просто контейнер, где находятся вершины модели, индексный буфер для обращения к вершинам через многоугольники и таблица дополнительных атрибутов для группировки множества многоугольников с учетом материала поверхности. В большинстве случаев класс TD3DXMesh предоставляет тот самый метод хранения данных, которым вы, вероятно, пользовались, строя свои собствен- ные геометрические контейнеры, а потому подходит для решения многих и многих задач, связанных с нашим 3D-ядром. Иногда, правда, мы будем обнаруживать, что геометрия
36 Глава 2 ландшафта не вполне соответствует методу хранения в iD3DXMesh, и нам придется соз- дать собственный формат ее представления. Но даже тогда, когда это произойдет, мы сможем и дальше обращаться к TD3DXMesh как промежуточному формату для считывания данных и сохранения их на диске. ОЗОХ-меши имеют много специфических особенностей, каждая из которых нацелена на ту или иную задачу. Класс, который мы обсуждали до сих пор, является главным кон- тейнером мешей для описания статической геометрии. Помимо этого класса, в D3DX есть специально созданные классы упрощающих (simplification mesh) - ID3DXSPMESH и про- грессивных мешей (progressive mesh) - id3dxpmesh. Первый из них дает возможность сократить число граней или вершин с участием весов, определяющих сравнительную значимость тех или иных компонентов модели. Вызвав процедуру упрощения модели, вы избавитесь от наименее значимых ее элементов. Это одноразовая операция, действие которой нельзя отменить и которая по этой причине наиболее уместна при подготовке моделей к их дальнейшему применению. Прогрессивные меши - альтернатива необратимой редукции, присущей упрощающим мешам. В основе их лежит метод видонезависимого прогрессивного меша (View Indepen- dent Progressive Mesh), описанный Хьюзом Хоппе (Hughes Hoppe) [Норре]. Метод Хоппе построен на серии операций дробления треугольников, каждая из которых называется раз- биением (split) и которые можно использовать для повышения и понижения сложности модели в реальном времени. Объединив смежные многоугольники и зафиксировав резуль- тат, можно уменьшить видимую сложность модели. Отказавшись от такого объединения и разбив треугольники до их исходной величины, можно восстановить начальную слож- ность поверхности. Операции разбиения и объединения допускают итеративное выполне- ние, что дает возможность плавно переходить от моделей с высокой четкостью к моделям с низкой четкостью и обратно. Это позволяет пользоваться одним прогрессивным мешем как вблизи камеры, когда необходима высокая степень детализации, так и при удалении поверхности от наблюдателя. Загрузка и отображение модели в D3DX Учебные материалы DirectX SDK показывают: загрузка и отображение простых мешей D3DX являются элементарной задачей. Читатель, не знакомый с объектами TD3DXMesh, имеет прекрасную возможность узнать о них из справочника по SDK. Здесь же в порядке обзора мы обрисуем только основы применения классов D3DX, не повторяя в деталях то, что уже сказано в документации к библиотеке. Пусть приложение и среда Direct3D инициализированы, в нашем случае- при помощи DirectX Sample Framework, тогда отображение меша на экране сведется к ее чтению с диска, созданию объектов, необходимых для рендеринга, и выводу ее содержи- мого с участием этих объектов.
ОСНОВНЫЕ ЗО-ОБЪЕКТЫ 37 Первый шаг - загрузка меша из .Х-файла с применением функции D3DXLoadMeshFromFile. Эта функция D3DX загружает геометрию меша, выделяет память для списка требуемых мешу структур D3DXMATERIAL и заполняет построенный список. Каждый вид материала может иметь дополнительную строку с указанием имени файла текстуры. Передав имя текстуры функции D3DXLoadTextureFromFile, можно считать в память растровую текстурную информацию. Если ни текстура, ни модель не нуждаются в преобразовании их геометрии, данные готовы к рендерингу. Проще всего рендеринг меша осуществить при помощи «грубой силы». Используя этот метод, мы выведем все меши в составе сцены одну за другой, при этом не пытаясь формировать пакеты подобных структурных элементов или как-то иначе повышать произ- водительность вычислений, эффективно упорядочив вызовы процедуры рендеринга. Каждый меш при рисовании делится на подмножества. Каждое подмножество - это набор многоугольников с одинаковыми свойствами материала. Тем самым, если !D3DXMesh содержит п подмножеств, ее рендеринг должен производиться с учетом п соответствующих материалов и п карт текстур, созданных при загрузке. Отображая данный меш, мы попросту организуем цикл по всем подмножествам, активизируя нужные текстуры и зада- вая свойства активных материалов. После надлежащей активизации объектов мы можем инициировать рендеринг подмножества меша, вызвав функцию-член Drawsubset. Применение файлов эффектов Direct3D При построении 3 D-движка необходимо учитывать широкий спектр современной аппаратуры домашних компьютеров. Процедуры рендеринга, которые успешно справ- ляются с задачей на видеокартах одного типа, могут быть крайне неэффективны на других типах видеокарт, а то и вовсе те могут их не поддерживать. Единственно приемлемое решение состоит в организации нескольких таких процедур, что позволяет обеспечить работоспособность программы на многих аппаратных платформах. Создать необходимый набор, а значит, и объединить ряд вариантов рендеринга позволяют файлы эффектов Direct3D. Каждый вариант, описанный в таком файле, является абстрактным представле- нием конкретного приема рендеринга, который содержит как сведения о структуре, так и необходимые команды вершинных и пиксельных шейдеров. Обеспечить же обратную совместимость помогает создание файла эффектов на базе модульного принципа описания приемов рендеринга. Файл эффектов - это обычный текстовый файл, в котором находится перечень доступ- ных нам вариантов. Каждый из них может предполагать ряд проходов с описанием инструк- ций и ресурсов, необходимых для выполнения единой процедуры рендеринга. Следуя такой иерархии, в эти файлы можно включать множество разных вариантов, по одному на каждый
38 Глава 2 класс поддерживаемых аппаратных устройств, при этом каждый вариант, еслй необходимо, может содержать команды для выполнения нескольких проходов по алгоритму. Текстовая природа файлов эффектов позволяет с легкостью их редактировать, а также использовать для быстрого изготовления прототипов. Компиляция таких файлов во время работы дает возможность получать не только описание состояний рендеринга, но и коман- ды шейдеров, предназначенные для оборудования, на котором выполняется ваша про- грамма. Эффективность вариантов, описанных в файле эффектов, можно оценить при компиляции, что позволяет найти вариант, наиболее подходящий для данной аппаратной конфигурации. Этот подход ведет к созданию высокомодульной системы с поддержкой множества как нынешних, так и будущих аппаратных платформ, для работы с которыми при появлении новых устройств достаточно выпустить дополнительные файлы эффектов. Листинг 2.1, содержащий простой файл эффектов, иллюстрирует иерархию вариантов и проходов по алгоритму. Для краткости в нем оставлены лишь стадии работы с тек- стурой. Обычно в файле эффектов находятся и команды вершинных и пиксельных шей- деров, что расширяет возможности по управлению процессом рендеринга в соответствии с каждым из вариантов. Этот файл призван продемонстрировать, как множество вариан- тов рендеринга помогает в поддержке разнообразных устройств. Многие видеокарты отличаются количеством текстур, которые они могут использовать за один проход алгоритма. Для получения одного и того же видимого эффекта обоим приемам, представ- ленным в .листинге 2.1, требуется шесть текстур. Первый обращается к каждой из шести текстур за один проход, второй обращается к ним же за два прохода: за первый проход он использует четыре гекстуры, за второй - две. Если откомпилировать файл эффектов для данной аппаратной платформы, однопроходный алгоритм будет допустим лишь тогда, когда платформа позволит использовать шесть текстур за проход. В случае поддержки меньшего числа текстур первый вариант станет непригодным, и программе будет дана команда перейти на применение второго варианта. Листинг 2.1. Пример файла эффектов, содержащего одно- и многопроходный варианты рендеринга // Оба представленных далее варианта используют четыре // одинаковые текстуры, для описания которых служат // переменные, объявленные ниже. В программе эти переменные // будут указывать на конкретные, расположенные в памяти // объекты класса D3DTexture9. texture texO; texture texl; texture tex2; texture tex3; // Первый вариант осуществляет рендеринг всех четырех // текстур одновременно. Работа с текстурами заключается
ОСНОВНЫЕ ЗО-ОБЪЕКТЫ 39 // в сложении цветов всех четырех текстур, technique to { pass рО { // все четыре текстуры загружаются в память // и помещаются во входные параметры Texture[0] = (texO); Texture[1] = (texl); Texture[2] = (tex2); Texture[3] = (tex3); // значения цветов первой текстуры // используются "как есть" ColorOp[0] = SelectArgl; ColorArgl[0] = Texture; // значения цветов прочих текстур // добавляются (Add) к содержимому // предыдущего канала с текстурами ColorOptl] = Add; ColorArgl[1] = Texture; ColorArg2[l] = Current; ColorOp[2] = Add; ColorArgl[2] = Texture; ColorArg2[2] = Current; ColorOp[3] = Add; ColorArgl[3] = Texture; ColorArg2[3] = Current; ColorOp[4] = Disable; } } // Второй вариант предполагает рендеринг текстур // за два прохода. При первом проходе осуществляется // рендеринг двух первых текстур, при втором - // двух оставшихся. Оба прохода объединяются в // буфере кадра, что позволяет добиться результата, // аналогичного первому. technique tl { ' // первый проход. Отобразить две текстуры из четырех pass рО { // при этом проходе смешивание не выполняется
40 Глава 2 AlphaBlendEnable = False; // четыре текстуры загружаются в память // и помещаются во входные параметры Texture[0] = (texO); Texture[1] = (texl); Texture[2] = (tex2); Texture[3] = (tex3) ; // значения цветов первой текстуры // используются "как есть" ColorOp[0] = SelectArgl; ColorArgl[0] = Texture; // вторая текстура добавляется к первой ColorOpfl] = Add; ColorArgltl] = Texture; ColorArg2[l] = Current; ColorOp[2] = Disable; } // второй проход. Дорисовать две последние текстуры, // смешать результат с результатом первого из проходов pass pl { // смешать результат этого прохода // с результатом первого AlphaBlendEnable = True; SrcBlend = One; DestBlend = One; // две текстуры загружаются в память, // и помещаются во входные параметры Texture[0] = (tex4); Texture[1] = (tex5); // значения цветов первой текстуры // используются "как есть" ColorOp[0] = SelectArgl; ColorArgl[0] = Texture; // вторая текстура добавляется к первой ColorOp[l] = Add; ColorArgl[l] = Texture; ColorArg2[l] = Current; ColorOp[2] = Disable;
ОСНОВНЫЕ ЗО-ОБЪЕКТЫ 41 Чтобы использовать файл эффектов в своей программе, вы должны обратиться к биб- лиотеке D3DX, в которую входит набор простых функций загрузки и проверки примени- мости конкретных вариантов, описанных в этом файле. Самый простой и распространенный путь - использовать один файл эффектов на каждый метод рендеринга. Каждый файл может содержать несколько отдельных вариантов реализации одного метода. Более перспективные варианты помещаются в начале этого файла, затем, по убыванию, следуют версии для более старого и не столь мощного оборудования. Именно так написан пример, показанный в лис- тинге 2.1. Подобная схема файла эффектов дает D3DX возможность загружать файлы и находить наиболее удачный эффект, способный работать на целевой платформе. Функциями D3DX для выполнения этих действий являются D3DXCreate- Ef fectFromFile и FindNextValidTechnique. Первая из них считывает файл эффектов с диска и компилирует его для запуска в приложении. Все ошибки, найденные в файле при компиляции, выводятся через стандартный объект D3DXBuffer. В случае успешной компиляции строится объект !D3DXEffect, предоставляющий интерфейс к откомпи- лированному эффекту. Для выбора лучшей реализации эффекта можно вызвать функцию-член (member function) FindNextVal idTechnique, которая позволяет обойти все варианты реализации и проверить их работоспособность на установленном оборудовании. Параметром FindNextValidTech- nique является описатель варианта реализации; задача функции - найти очередной допусти- мый вариант эффекта, следующий за переданным как параметр. Если на вход функции пода- ется NULL, то FindNextValidTechnique осуществляет поиск с начала файла и возвращает первый найденный допустимый вариант. Если приемы реализации перечислены по убыванию их вычислительной эффективности, то, вызывая FindNextValidTechnique, мы получим самую удачную версию метода, которая станет работать на данной аппаратуре. Небольшой фрагмент кода, где выполняется загрузка файла эффектов и производится поиск лучшей реали- зации, приведен в листинге 2.2. Листинг 2.2. Загрузка файла эффектов и поиск лучшей реализации // глобальные переменные для указания на // используемый эффект и вариант его реализации LPD3DXEFFECT m_pEffect=0; D3DXHANDLE m_hTechnique=O; HRESULT loadEffectAndSetTechnique( const char* filename) { // загрузить файл эффектов, расположенный по // указанному пути. Пример не нуждается ни // в каких дополнительных макроопределениях // или ссылках на подключаемые файлы
42 Глава 2 LPD3DXBUFFER pBufferErrors = NULL; HRESULT result = D3DXCreateEffectFromFile( g_d3dDevice, filename, NULL, NULL, 0, NULL, &m_pEffeet, &pBufferErrors ) ; if( FAILED( result ) ) { // здесь можно проверить состояние буфера // pBufferErrors и выяснить причину неудачного // завершения, мы же просто вернем код ошибки SAFE_RELEASE(pBufferErrors); return result; } // работа с буфером ошибок завершена SAFE_RELEASE(pBufferErrors); // найдем лучший вариант реализации, // организовав поиск с начала файла эффектов; // наша цель - первый из допустимых вариантов. result = m_pEffect->FindNextValidTechnique( NULL, &m_hTechnique); if( FAILED( result ) ) { // ни один допустимый вариант не найден. // освободим интерфейс эффектов // и сообщим об ошибке SAFE_RELEASE(m_pEffect); return result; } // активизируем найденный вариант result = m_pEffect->SetTechnique(m_hTechnique); if( FAILED( result ) ) { // активизация не удалась. // освободим интерфейс эффектов // и сообщим об ошибке SAFE_RELEASE(m_pEffect);
ОСНОВНЫЕ ЗБ-ОБЪЕКТЫ 43 return result; } // новый вариант готов к применению return D3D_0K; } Кроме того, немалую гибкость файлы эффектов приобретают за счет наличия в них пиксельных и вершинных шейдеров разного рода. Пример листинга 2.1 не содержит информации о шейдерах, предполагая, что используется фиксированный (fixed-function) конвейер - предшественник всех программируемых шейдеров. При желании код пиксель- ных и вершинных шейдеров можно поместить прямо в текст файла эффектов. Это дает возможность инкапсулировать в него полное описание того или иного метода рендеринга. Пиксельные и вершинные шейдеры можно определять в файле эффектов, пользуясь для этого языками ассемблера каждого из них или новым языком описания шейдеров высо- кого уровня (HLSL), доступным при работе с DirectX 9.0. Внедрение HLSL-шейдеров станет предметом изучения в следующей главе. Контейнеры кадров и мешей в D3DX Всякая модель пространства игры связана с узлом преобразования (transform node). Этот узел обычно представлен матрицей, задающей систему координат для вершин моде- ли. Узлы преобразований, также именуемые кадрами^ (frames), дают возможность помес- тить модель в том месте пространства, где мы захотим. Объединяя узлы и модели в иерархическую структуру, можно формировать более крупные объекты, после чего ани- мировать их, меняя во времени параметры узлов преобразований. Для размещения этой иерархии в памяти и последующей анимации результата в D3DX служит пара удобных структур - D3DXFRAME И D3DXMESHCONTAINER . D3DXFRAME - это обычная структура, созданная для представления единичного узла преобразований в составе иерархии модели. В ней содержится матрица преобразо- ваний, определяющая систему координат узла, есть указатели на потомков и сестрин- ские узлы типа D3DXFRAME, лежащие в дереве иерархии, имеется указатёль на d3Dxmeshcontainer. Кроме того, D3DXFRAME содержит указатель на тестовое имя (test name) самого кадра, если таковое имеется. Цель именования кадров - в том, чтобы позднее, при наложении анимации, по-прежнему иметь возможность их распознать. Общая схема структуры D3DXFRAME показана в начале листинга 2.3. 1 Frame (кадр) в DirectX - это невидимый куб, предоставляющий связи для объектов в сцене. Видимые объекты получают свои положения и ориентацию из кадров Не путать с кадром в видео и анимации, где под кадром понимается одиночное изображение из набора изображений. - Примеч. науч ред
Глава 2 ЛИСТИНГ 2.3. Структуры D3DX для построения иерархии данных модели typedef struct _D3DXFRAME { LPTSTR Name; D3DXMATRIX TransformationMatrix; LPD3DXMESHCONTAINER pMeshContainer; struct _D3DXFRAME *pFrameSibling; struct _D3DXFRAME *pFrameFirstChild; } D3DXFRAME, *LPD3DXFRAME; typedef struct _D3DXMESHCONTAINER { LPTSTR Name; D3DXMESHDATA MeshData; LPD3DXMATERIAL pMaterials; LPD3DXEFFECTINSTANCE pEffectS; DWORD NumMaterials; DWORD *pAdjacency; LPD3DXSKININFO pSkinlnfo; struct _D3DXMESHCONTAINER *pNextMeshContainer; } D3DXMESHCONTAINER, *LPD3DXMESHCONTAINER; Вторая структура dsdxmeshcontainer полностью соответствует своему названию. Ее формат представлен в конце листинга 2.3. Все объекты ID3DXMESH в иерархии хранят- ся в структурах d3dxmeshcontainer и образуют дерево, вершины которого связаны по указателям на объекты dsdxmeshcontainer в составе структуры D3dxframe. Указа- тель на сам'меш, а также код, определяющий тип меша в памяти, располагается в объекте d3dxmeshdata структуры dsdxmeshcontainer. Меши могут храниться как обычные объекты iDSDXMesh или объекты типов lD3DXPMesh и !D3DXPatchMesh. Кроме того, в структуре, где находится меш, есть указатели на данные, созданные в процессе импорта модели. К ним относятся сведения о материалах поверхности меша, смежности граней модели, а также адрес структуры, содержащей данные о необязатель- ном объекте D3DXEffect, используемом при рендеринге модели. Есть в структуре dsdxmeshcontainer и указатель на интерфейс iD3DXSkininfo, позволяющий мешу, находящемуся в контейнере, вести себя подобно отекстуренному мешу (skinned mesh). Каждая вершина подобного меша имеет свой вес и свой порядковый номер, что дает воз- можность деформировать модель при помощи целого ряда матриц преобразований D3DXFRAME, включенных в состав иерархии. Располагая дополнительной информацией о текстурах, дерево объектов D3DXFRAME можно свести в единую скелетную модель, пригодную для последующей анимации.
ОСНОВНЫЕ ЗР-ОБЪЕКТЫ 45 Благодаря этому иерархия в целом может использоваться как традиционный граф сцены, составленный из моделей, находящихся в отношении «родитель - потомок», как завершенная структура скелета, готовая для деформации меша, или как то и другое одно- временно. Еще большую гибкость D3DXMESHCONTAINER придает последний член этой структуры, являющийся простым указателем на следующий ее экземпляр. Его наличие дает возможность соединить с одним узлом dsdxframe несколько контейнеров мешей, каждый из которых может содержать либо не содержать текстурную информацию о модели (см. рис. 2.1). Рис. 2.1. Пример иерархии мешей, общий скелет которой образован как моделями с текстурами, так и моделями, имеющими только каркас Создание и объекты, которые в них содержатся, будут размещены в памяти и связаны между собой в дерево. Для чтения мешей, материалов и текстур можно использовать процедуры загруз- ки !D3DXMesh, к которым мы обращались в конце первой главы книги. Затем эти меши можношоместить в контейнеры d3dxmeshcontainer, расположив их в нужных вершинах дерева. Используя этот метод, можно объединять иерархии отдельных мешей в более крупные объекты или создавать на их основе целые сцены. таких иерархий предполагает, что все необходимые структуры данных, как
46 Глава 2 В D3DX есть и гораздо более простые средства организации большого дерева данных. Многие системы ЗО-моделирования, такие, как discreet® 3ds max™ и Alias|Wavefront® Maya®, позволяют строить сцены, состоящие из- немалого числа узлов и моделей. Такую иерархию можно трактовать как единую сцену и предоставить художникам возможность ани- мировать ее узлы с целью перемещения каркасных сеток или деформации покровных объек- тов. Чтобы экспортировать всю сцену в базу данных, где контейнеры кадров и сеток хранятся как единый Х-файл, можно воспользоваться плагинами (подключаемыми модулями) для работы с такими файлами, которые разработаны для наиболее популярных 3 D-пакетов, включая упомянутые выше. В саму библиотеку D3DX входят такие функции загрузки иерархий, как D3DXLoadMeshHierarchyFromX, которая по содержимому Х-файла может построить целое дерево объектов D3DXFRAME И D3DXMESHCONTAINER. Более подробно эти функции будут рассмотрены в главе 4 «Обзор движка Gaia», где мы приступим к импорту иерархий собственных анимированных мешей. Скелетная анимация и отекстуренные меши Информация об отекстуренных мешах становится известной в программе в процессе загрузки иерархии D3DXFRAME. Хотя объект lD3DXSkininfo с информацией о текстурах можно построить вручную, намного проще создать отекстуренный меш в профессиональ- ной моделирующей среде, экспортируя ее в файл Х-формата. Используя функцию D3DXLoadMeshHierarchyFromX, в память можно загрузить иерархию узлов целого кадра, а также другие сведения о текстурах и анимации. Итак, что это за данные? Объект iD3Dxskininfo содержит множество данных, отно- сящихся к каждой вершине нашей модели. Сама модель, находящаяся в другом месте, может располагаться в векторном буфере - объекте lD3DXMesh или в системной памяти. lD3DXSkininfo включает в себя лишь данные, необходимые для деформации вершин меша с участием множества матриц, которые тоже хранятся в этом наборе данных. В данном объекте находятся номера матриц, используемых при работе с каждой верши- ной, и скалярные значения весов для регулировки воздействия на каждую вершину каждой отдельной матрицы. Это - стандартный набор данных для прорисовки текстур с индексированной палитрой (indexed palette skinning). Теперь нам видна вся картина. В составе иерархии D3DXFRAME мы имеем набор вложен- ных матриц преобразований, которые определяют систему локальных скелетов. При помощи d3dxmeshcontainer объекты d3dxframe можно объединять в иерархию и придавать модели такое положение и такую ориентацию, которые выбраны для нее роди- тельскими вершинами. Указатели на объекты iD3DXSkinlnfo в составе каждого D3DXMESHCONTAINER дают возможность обращаться к данным, необходимым для деформа- ции модели, лежащей в контейнере мешей, и деформировать ее представление в отдельных
ОСНОВНЫЕ ЗО-ОБЪЕКТЫ 47 узлах D3DXFRAME, тем самым создавая отекстуренный меш. Загрузка и создание всей сцены, как мы увидим это в главе 4, являются делом функции D3DXLoadMeshHierarchyFromX. Последний недостающий фрагмент - данные об анимации сцены и инструмент для управле- ния воспроизведением анимации по данным в иерархии d3dxframe. Интерфейсы классов, где находятся сведения об анимации и воспроизведении, также имеются в библиотеке D3DX. Контейнером для самих анимационных данных служит класс lD3DXAnimationSet, а интерфейс для связи этих данных с набором матриц (к примеру, деревом структур d3dxframe), которым будет управлять анимационная процедура, предос- тавляет класс !D3DXAnimationControl ler. Кроме того, он дает возможность контролиро- вать воспроизведение анимации и накладывать сразу несколько анимационных эффектов. Использовать эти классы очень легко. В узлах d3dxframe находятся матрицы, которые класс lD3DXAnimationController регистрирует, используя строковые имена структур D3DXFRAME. Неименованные матрицы нельзя анимировать, а потому для связи самих матриц с данными об анимации служат их имена. Класс iD3DXAnimationController содержит указатели на один или несколько объектов lD3DXAnimationSet, где хранятся данные об анимации по ключевым кадрам- данные, которые можно воспроизвести при помощи объекта-контроллера. В составе этих данных - информация об операциях поворота, переноса и масштабирования, выполняе- мых над матрицей с течением времени. Кроме того, здесь содержатся имена матриц, изме- няемых в каждом ключевом кадре. Класс ID3DXAnimationController регистрирует лишь те матрицы, что имеют свои имена, которые, в свою очередь, сопоставляются с на- бором именованных ключевых кадров, и между ними устанавливается связь. Так, матри- ца, известная как «левое колено», будет обновляться с применением тех анимационных данных, которые имеют соответствующее имя. Вся процедура регистрации матриц автоматически осуществляется при вызове функции D3DXLoadMeshHierarchyFromX для загрузки и сборки иерархии кадра на основе Х-файла, в котором содержится анимационная информация. В итоге зарегистрированные матрицы станут автоматически обновляться классом !D3DXAnimationController при воспроизве- дении анимации. Связь между данными об анимации и именованными матрицами в составе йерархии модели отражена на рис. 2.2. lD3DXAnimationController предоставляет интерфейс для совместного применения разных анимационных эффектов и настройки таких параметров их воспроизведения, как скорость и приоритет при наложении. Разрешить воспроизведение результата можно, только присвоив lD3DXAnimationSet отдельным анимационным дорожкам ID3DXAni- mationController. Это позволит анимировать каждый объект ID3DXAnimationSet независимо от других, управляя скоростью и весовым коэффициентом при наложении той
48 Глава 2 дорожки, с которой связан объект. Для наложения нескольких анимационных дорожек активные дорожки должны быть отнесены к числу низко- или высокоприоритетных. От приоритета дорожки зависит то, как происходит ее смешение с другими дорожками. Рис. 2.2. Связь поименованных матриц и ключевых кадров, анимируемых средствами класса ID3DXAnimationController Установка приоритета дорожки требует применения функции SetTrackDesc из класса iD3DXAnimationController, которая загружает структуру D3DXTRACK_DESC, содержащую данные о дорожке. Первым параметром этой структуры является поле с именем flags. К сожалению, в документации к DirectX 9.0 SDK сказано, что это поле не используется, хотя это не так. Придав этому флагу одно из значений d3dxtrackflag (D3DXTF_LOWPRIORITY ИЛИ D3DXTF_HIGHPRIORITY), ВЫ ВКЛЮЧИТе Эту Дорожку В Группу с тем или иным приоритетом. В будущих версиях SDK эта ошибка в документации, мы надеемся, будет исправлена. Данные об анимации активных дорожек объединяются, и процесс их объединения делится на три этапа. Сначала с учетом весовых коэффициентов будут смешаны все дорожки из группы с низким приоритетом. Затем с учетом весов, установленных для каждой дорожки, будут смешаны все дорожки из группы с высоким приоритетом. Наконец, результаты смешения тех и других накладываются друг на друга с учетом ска- лярной величины, заданной самим контроллером анимации. Это значение можно устано-
ОСНОВНЫЕ ЗО-ОБЪЕКТЫ 49 вить при помощи функции-члена SetPriorityBlend, а настроить характер его измене- ния со временем - при помощи функции KeyPriorityBlend. На рис. 2.3 показаны этапы наложения и создания итоговой анимации на основе множества смешанных между собой анимационных дорожек. Рис. 2.3. Три этапа объединения анимационных дорожек в классе iD3DXAnimationController Кроме того, следует сказать о том, что в процессе объединения отдельных дорожек в группе по приоритету указанные для них значения не оказывают прямого воздействия на вклад каждой дорожки в результат их смешения между собой. Иными словами, задание коэффициента 0,5 не гарантирует того, что вклад дорожки в итоговый результат составит 50%. Значения весов дорожек не абсолютны, а относительны и принимаются в расчет лишь с учетом весов других дорожек в той же группе. Если группа содержит, например, две дорожки, каждая из которых обладает, весом 0,5, то результат их смешения будет таким же, как если бы обе дорожки имели весовой коэффициент 1,0. Так или иначе, обе дорожки имеют одинаковый вес, и их вклад в результате окажется равным. Если же вес дорожки А составляет 0,5, а вес дорожки В - 0,25, то вклад дорожки А окажется вдвое большим, так как ее вес в два раза превышает вес дорожки В. Чтобы в полной мере использовать все преимущества скелетной анимации D3DX, построим вокруг интерфейсов библиотеки свои собственные структуры данных, призван- ные обеспечить нас дополнительными интерфейсными методами. Создание части новых интерфейсов - попросту дело вкуса, однако модульный дизайн компонентов D3DX дает возможность вводить в приложение функции, которые пожелаем мы сами.
50 Глава 2 К примеру, мы хотим, чтобы контроллер анимации оповещал нас о том, что ее демон- страция завершена. В классе lD3DXAnimationController можно узнать лишь продол- жительность анимации, а также то, сколько времени с начала ее показа уже истекло. При этом бремя мониторинга контроллера анимации и предсказания момента окончания воспроизведения ложится на нашу программу. Для упрощения процесса разработки мы создадим класс, который будет сам контролировать истекшее время и обратным вызо- вом оповещать приложение о завершении анимации. Так мы организуем основанную на сообщениях систему обратной связи с анимацией, что сделает написание кода объектов игры чуть более простым делом. Подобные интерфейсные вставки опять-таки являются делом личного вкуса. Созда- ние нового класса вокруг ID3DXAnimationController дает возможность включать в него те интерфейсы, которые мы считаем уместными. В главе 4 мы создадим свой собст- венный анимационный контроллер И СВОИ версии D3DXFRAME И D3DXMESHCONTAINER, которые расширят возможности стандартных структур новыми функциями для решения наших конкретных задач. Состав и организация библиотеки D3DX предоставляют нам право делать подобные расширения без ущерба для работы с базовыми функциями из ее первоначального варианта. Однако в классе ID3DXAnimationController есть ряд пробелов, восполнить которые не так-то легко. Этот класс содержит ряд интерфейсов, полезных для управления выводом анимации. Его функции-члены, такие, как KeyTrackSpeed и KeyTrackWeight, дают возможность динамически расставлять на активных дорожках анимации ключевые кадры, позволяющие повышать и понижать скорость воспроизведения, а также контро- лировать вес каждой дорожки при смешении в разные моменты времени. Благодаря названным интерфейсам мы можем задавать значения параметров, автоматически интерполируемых при помощи линейной или более гладкой - сплайновой - интерпо- ляции, что делает эту возможность очень полезной при переходе от одной анимации к дру- гой. Неожиданным упущением создателей стало то, что интерфейс для удаления дина- мических ключевых кадров отсутствует. Как только интерполяция введена, ее результат отменить уже невозможно. Это обстоятельство делает функции KeyTrack бесполезными; вызывать же их можно лишь в случае полной уверенности в том, что вам не понадобится изменять их параметры после того, как те вступят в действие. Литература [Hoppe] Hoppe, Н. «Progressive Meshes» ACM SIGGRAPH 1996, p. 99-108 (статья дос- тупна по адресу http://research.microsoft.com/users/hhoppe).
Глава 3 ЯЗЫК ОПИСАНИЯ ШЕЙДЕРОВ hlsl С появлением программируемых пиксельных и вершинных шейдеров в библиотеке DirectX 8.0 специалистам, занятым компьютерной графикой, открылся целый мир новых возможностей трехмерной визуализации. Арсенал программистов и художников уже не ограничивался тем, что предлагали им те или иные производители аппаратуры, в нем появились самые экстравагантные приемы рендеринга, обращение к которым придавало созданным мирам подлинную уникальность. Немало нового в создании программируемых шейдеров появилось и в DirectX 9.0. И дело не только в том, что описание пиксельных и вершинных шейдеров стало более функциональ- ным благодаря возможности применять в них большее количество инструкций и данных, но и в том, что для создания шейдеров теперь можно использовать новый С-подобный язык, который делает кодирование более простым, а сам код - более доступным для понимания. Введенный в DirectX 9.0 язык High-Level Shading Language (HLSL) - это огромный скачок вперед. Создатели шейдеров теперь могут писать их в формате более знакомого им языка и компилировать результат под конкретную целевую платформу. Процедура компиляции гарантирует, что умело написанный шейдер будет использо- ваться и на тех аппаратных средствах, которые появятся в будущем, и это не потребует обратной совместимости или применения вышедших из употребления методов. Старые шейдеры можно откомпилировать заново, используя в них последние новшества в специ- фикациях вершинных и пиксельных языков и давая этим шейдерам «вторую жизнь» на ап- паратуре будущего. Альтернативные шейдерные языки, такие, как Cg, созданный в стенах NVIDIA, свидетельствует о перспективности создания именно кросс-платформенных шейдеров. Короче говоря, решение графических задач выросло из умения применять экранные функции операционных систем и стало подлинным языком разработки, создан- ным для написания программ, выполняемых на разных аппаратных платформах. В этой главе мы подробно изучим имеющийся в DirectX SDK язык HLSL. Мы расска- жем о его структуре, выражениях и характерных лишь для него типах данных, а также приведем несколько примеров распространенных шейдеров, которые можно построить,
52 Глава 3 используя этот язык. Кроме того, мы обратимся к применению HLSL во время работы программ, опишем методы, дающие возможность приложениям на C++ взаимодейство- вать с программами на HLSL и управлять их выполнением. Помимо изучения HLSL, мы рекомендуем вам ознакомиться и с Cg - языком, который создан NVIDIA. Эти языки поразительно схожи и являются просто двумя путями решения одной задачи - создать максимально понятный пиксельный или вершинный шейдер, допус- кающий повторное использование своего кода. Для простоты при написании шейдеров, пред- ставленных в этой книге, мы будем пользоваться HLSL. Однако каждый описанный здесь метод работы над шейдером легко перенести и в Cg. Ссылки на источники информации о языке программирования Cg приведены в приложении D «Рекомендуемая литература». Формат шейдера в HLSL Пользуясь HLSL, вы можете писать два типа шейдеров: пиксельные и вершинные. Их название говорит само за себя: шейдеры вершинного типа модифицируют данные о вершинах, пиксельные шейдеры рассчитывают цвета, которые служат для рисования на экране объектов. Так как задачи, которые они решают, на самом деле различны, то и структура самих шейдеров имеет небольшие различия. Чтобы ознакомиться с тем и другим типом шейдеров, рассмотрим их один за другим, после чего покажем, как ими можно пользоваться совместно, в пределах одного файла эффектов D3D. В простейшем случае вершинный шейдер - это единственная функция, написанная на языке HLSL. Она принимает от приложения входные данные конкретного типа, а именно вершины модели, и возвращает информацию, необходимую для визуализации модели на экране. Затем полученная информация может стать входом шейдера пиксельного типа, который интерпретирует се и создаст окончательное представление нашей модели. Любые операции вершинного шейдера связаны с геометрией модели. Этот шейдер, конечно же, может рассчитать цвет или освещенность отдельной вершины, однако в нем нс применяются никакие текстуры. Все, что доступно вершинному шейдеру, - это конкрет- ная вершина исходной модели, а также множество доступных только для чтения констант, которые может установить прикладная программа. Пример вершинного шейдера, написанно- го на HLSL, позволит вам представить себе базовую структуру его функции (см. листинг 3.1). ЛИСТИНГ 3.1. Пример программы - вершинного шейдера на HLSL // матрица преобразования // "объект - экран" float4x4 WVPMatrix : WORLDVIEWPROJECTION; /./ описание данных, которые будут // созданы нашим вершинным шейдером struct VS_OUTPUT
ЯЗЫК ОПИСАНИЯ ШЕЙДЕРОВ hlsl 53 { float4 Pos : POSITION; float2 Tex : TEXCOORD0; }; // вершинный шейдер в кодах на HLSL. // его функция принимает на вход вершину модели // и переносит ее в пространство экранных координат VS_OUTPUT VS( float3 Pos : POSITION, float2 Tex : TEXCOORDO) { // объявим структуру данных для вывода VS_OUTPUT Out = (VS_OUTPUT)0; // перенесем вершину из объектного пространства // в пространство экрана и сохраним ее в нем // выходная структура Out.Pos = mul( float4(Pos, 1), WVPMatrix); // кроме того, скопируем в выходную // структуру UV-координаты текстуры Out.Тех = Тех; return Out; } Как и программы на С, шейдеры на HLSL могут иметь три основные составляющие: объявления переменных, описания типов и собственно функции. В коде листинга 3.1 показан элементарный шейдер для перевода вершин модели из объектного пространства в экранное. При переводе он позволяет сделать привязку координат текстуры к каждой вершине модели. В дальнейшем, при прохождении через пиксельный шейдер, знание этих координат поможет нанести текстуру на поверхность каждого многоугольника. В своем шейдере мы объявляем переменную, описываем структуру и задаем функцию самого вершинного шейдера. Этот язык очень похож на С, но между ними есть принципиальные расхождения. Рассмотрим и прокомментируем каждый компонент языка по порядку. Переменные и типы данных HLSL имеет знакомый набор типов для работы со скалярными величинами. Наряду с ними язык содержит ряд типов данных для представления векторов, матриц, а также объектов, характерных для описания шейдеров. Поэтому в HLSL есть несколько разных путей задания новых типов, которые упрощают их применение. Полный перечень ска- лярных, векторных и матричных типов HLSL приведен в таблице 3.1.
54 Глава 3 Таблица 3.1. Типы данных, доступные в шейдерах на языке HLSL СКАЛЯРНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ bool Логические значения - истина или ложь int 32-разрядное целое со знаком half 16-разрядное вещественное число половинной точности float 32-разрядное вещественное число одинарной точности double 64-разрядное вещественное число двойной точности ВЕКТОРНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ vector Вектор из четырех вещественных чисел vectorCt, num> Вектор из num значений скалярного типа t МАТРИЧНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ matrix 4х4-матрица из 16 вещественных значений matrix <t, row, col> Матрица значений типа t размера row на col ОБЪЕКТНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ string ASCII-строка pixelshader Объект - пиксельный шейдер Direct3D vertexshader Объект - вершинный шейдер Direct3D sampler Объект описывает применение и фильтрацию текстуры texture Объект - текстура Direct3D ОПИСАНИЯ ВЕКТОРНЫХ ТИПОВ ОПРЕДЕЛЕНИЕ (# - ЗНАЧЕНИЯ от Одо 4) booi#x# То же, что vector <bool, #>. Пример: bobi4 int#x# То же, что vector <int, #>. Пример: int4 float#x# То же, что vector <float, #>. Пример: Ioat4 half#x# То же, что vector chalf, #>. Пример: half4 double#x# То же, что vector <double, #>. Пример: double4 ОПИСАНИЯ МАТРИЧНЫХ ТИПОВ ОПРЕДЕЛЕНИЕ (# - ЗНАЧЕНИЯ от 0 до 4) bool#x# То же, что matrix <bool, #, #>. Пример: Ьоо14х4 int#x# То же, что matrix <int, #, #>. Пример: int4x4 float#x# То же, что matrix <float, #, #>. Пример: float4x4 half#x# То же, что matrix <half, #, #>. Пример: half4x4 double#x# То же, что matrix <double, #, #>. Пример: double4x4 double#x# То же, что matrix <double, #, #>. Пример: double4x4
ЯЗЫК ОПИСАНИЯ ШЕЙДЕРОВ hlsl 55 Как это ни печально, но конкретные типы данных не обязательно имеются на всех аппаратных платформах. Разные производители аппаратуры поддерживают в своих про- дуктах разные типы данных, так что один или несколько таких типов могут не поддержи- ваться явно, а фактически эмулироваться. Так, не все производители реально поддержи- вают типы int, half или double. В отсутствие реальной поддержки эти типы эму- лируются при помощи float. Такое положение дел может приводить к неожиданным результатам, если значение, хранящееся как int или double, выходит за пределы отве- денного для float диапазона. По этой причине float рекомендуется сделать основным типом, применяемым при написании шейдеров. При этом int, double и half нужно исполь- зовать лишь в том случае, когда вам известен диапазон значений результата или вы пишете код для конкретной платформы. Как и в языке С, переменные для указания области видимости можно снабжать ключе- вым словом extern или помечать как static, const и volatile. Также HLSL содержит несколько новых слов, которые еще больше конкретизируют применение шейдерных переменных. Глобальные переменные и входные параметры можно объявлять как неиз- менные (uniform), что означает- переменная не меняется в процессе выполнения шей- дера (то есть во время вызова функции DrawPrimitive). Тем, кто знаком с написанием шейдеров на ассемблере, скажем: фактически это означает принадлежность значения к таблице констант шейдера. Второе ключевое слово shared указывает на то, что пере- менная является общей и известна нескольким HLSL-шейдерам одновременно. Обновле- ние общей переменной в одном объекте HLSL ведет к установке соответствующих пере- менных во всех шейдерах данной группы. Особые ключевые слова служат для описания входных параметров функций. Каждая входная переменная передается по своему значению. В отличие от C/C++, HLSL не позво- ляет передавать параметры по указателю или ссылке. Взамен параметры можно обо- значать особыми ключевыми словами, которые помечают их в качестве входных или выходных данных. Слово in при параметре указывает на то, что он является входной переменной, которая передана по значению. В отсутствие ключевых слов наличие специ- фикатора in предполагается самим компилятором. Служебное слово out позволяет параметру вести себя так, будто он является ссылкой, переданной функции на C++. По окончании ее работы содержимое out-параметра копируется в вызывающий метод, что напоминает передачу неконстантной ссылки на переменную. Последнее слово inout является простым сокращением и отражает тот факт, что параметр несет в себе входные данные, а его итоговое значение должно быть передано функцией в вызвавший ее метод. Вновь обращаясь к листингу 3.1, отметим применение семантики для уточнения всех перечисленных типов. Семантика (semantic) - это краткая запись с описанием предпола- гаемого способа интерпретации данных. В тексте вершинных шейдеров она идентична
56 Глава 3 семантике формата буферов вершин DirectX 9.0. Семантика начинается с двоеточия (:) и помещается сразу же за объявлением переменной, которая с ней будет связана. Если семантика служит для указания типа функции, ее помещают после объявления таковой: float4 VS(float3 Pos : POSITION, float2 Tex : TEXCOORDO) : POSITION Это объявление функции содержит три семантических элемента. С помощью семантики описан каждый входной параметр функции. Первый из параметров, Pos, содержит данные о положении в пространстве. Второй параметр, Тех, содержит координаты текстуры. Семан- тика текстурных координат содержит в себе схему нумерации, действие которой при необхо- димости можно расширить на дополнительные наборы координат. Сама функция возвра- щает вектор из четырех чисел с плавающей запятой. Последняя семантика помещается после объявления функции и указывает на то, что возвращаемое значение содержит данные о положении точки, а это именно то, что требуется от вершинного шейдера. В листинге 3.1 вы, должно быть, заметили, что шейдер возвращает вызвавшей его функции не единственную точку, а целую, пусть и небольшую, структуру. Эта структура наряду с данными о положении в пространстве содержит дополнительный набор тек- стурных координат. В определении структуры VS_OUTPUT каждый член данных помечен своей семантикой. Такая маркировка членов выходной структуры необходима, потому что далее она упростит производимый на конвейере перевод этой структуры во входные параметры шейдера пиксельного типа. Обозначать те или иные переменные!1 в программе можно и с помощью семантик, описанных самим пользователем. К примеру, в листинге 3.1 содержится объявление пере- менной для хранения матрицы 4x4, названной wvPMatrix. Эту матрицу сопровождает семантика worldviewprojection. Подобная запись не имеет особого значения в HLSL и введена в программу лишь для удобства. Так, поиск переменной в откомпилированном HLSL-шейдере можно вести по типу данных, имени или семантическому описанию. Обнаружение переменной дает возможность получить ее описатель, посредством которого приложение сможет изменить значение переменной до запуска шейдера. В результате поиска по семантике worldviewprojection наша программа сможет определить адрес wvPMatrix и загрузить в нее желаемую матрицу преобразования, реализующую перенос модели в пространство экрана. Встроенные функции и выражения языка Работа с данными в HLSL во многом напоминает работу в языке С или C++. Используя ряд переменных, вы можете выполнять расчет новых значений. Все выражения, допустимые в языке HLSL, зеркально повторяют те же выражения в С или C++, включая все матема- тические (+, *, / и т. д.), логические операторы и операторы отношений. Полный перечень таких выражений дан в приложении С «Краткое руководство по программированию».
ЯЗЫК ОПИСАНИЯ ШЕЙДЕРОВ hlsl Язык HLSL лишен такой роскоши, как стандартные математические библиотеки языка С, позволяющие выполнять более сложные операции, поэтому для решения этих задач в HLSL введен ряд встроенных функций. Одна из таких операций - функция mul, показанная в листинге 3.1. Часть этих функций, в том Числе mul, может напрямую транс- лироваться в инструкции ассемблера пиксельных или вершинных шейдеров. Так, обраще- ние к mul в примере листинга компилятор может сразу перевести в шейдерную инструк- цию низкого уровня, которая преобразует наш вектор с использованием указанной матри- цы. Трансляция других функций, среди которых refract, ведет к появлению более слож- ных низкоуровневых подпрограмм. В данном случае refract потребует расчета рефрак- ции луча (вектора) при прохождении им прозрачной среды. Реализуя шейдер в HLSL, необходимо помнить о том, что стоит за вызовом каждой встроенной функции, особенно при работе на устаревшей аппаратуре, которая серьезно ограничивает количество инструкций, которые могут входить в один шейдер. Полный список встроенных функций вы также найдете в приложении С. Работа с текстурами и сэмплерами Доступ к текстурам в пиксельных HLSL-шейдерах основан на применении текстурных сэмплеров (texture samplers). Сэмплер1 является местом хранения информации о стадии тек- стуризации (texture stage) Direct3D, включая ссылку на саму текстуру, а также информацию обо всех наложенных на нее фильтрах. Текстурный сэмплер нетрудно задать, воспользовав- шись определением статуса сэмплера (sampler state definition), которое поддерживают файлы эффектов DirectX. В это определение входят режимы заворачивания1 2 (wrap) координат тек- стуры и команды фильтрации мипмэпов3. Определение сэмплера содержит всю информа- цию, для установки которой в непрограммируемых конвейерах обычно служит функция SetSamplerState. Настройки сэмплера, а также допустимые значения этих настроек мы привели в табл. 3.2 (которую, помимо этой главы, вы найдете и в приложении С). В листинге 3.2 продемонстрирован пример элементарного пиксельного шейдера на языке HLSL. В целом он состоит из объявления текстуры и шаблона, предназначенного для обращения к ней. Прежде чем использовать шейдер, приложение должно присвоить тек- стурной переменной ТехО объект типа D3DTexture9. Шаблон указывает на то, что доступ к текстуре техО будет производиться при помощи нашего пиксельного шейдера, а для фильтрации всех множественных отображений будет использована линейная интерполяция. 1 Сэмплер - входной псевдорегистр для пиксельного шейдера, который используется для идентификации стадии текстуризации - Примеч науч ред 2 Процедура вычисления координат текстуры применительно к конкретной грани или мешу. - Примеч науч ред. 3 Мипмэп - последовательность текстур, каждая из которых является заранее просчитанной копией исходного изображения, но при этом каждая следующая текстура имеет размер меньший предыдущей Используется для обеспечения наилучшего качества текстуризации объектов на разном расстоянии - Примеч. науч. ред.
58 Глава 3 Таблица 3.2. Настройки текстурного сэмплера с указанием их значений СТАТУС СЭМПЛЕРА ТИП ДОПУСТИМЫЕ ЗНАЧЕНИЯ TAddressU dword WRAP = 1, MIRROR = 2, CLAMP = 3, BORDER = 4, MIRRORONCE = 5 AddressV dword To же, что в AddressU AddressW dword To же, что в AddressU BorderColor float4 Значение цвета; вектор, содержащий значения RGBA в диапазоне 0-1 MagRIter dword NONE = 0, POINT =1, UNEAR = 2, ANISOTROPIC = 3, PYRAMIDALQUAD = 6, GAUSSIANQUAD = 7 MinRIter dword To же, что в MagRIter MipRIter dword To же, что в MagRIter MaxAnisotropy dword Наибольшая анизотропия; по умолчанию 1 MaxMipLevel int Наибольшая глубина мипмэпа в диапазоне 0—п, где п - число доступ- ных мипмэпов. Самая крупная текстура имеет индекс 0. Самая мелкая - (п-1) MipMapLodBias float Величина смещения для выбранной глубины мипмэпа. По умолчанию 0,0 SRGBTexture bool True (ненулевое значение), если текстура сэмплирована в формате sRGB (с гамма-коррекцией 2,2). Более подробно о гамма-коррекции см. документацию к DirectX SDK Elementindex dword Если шаблон содержит текстуру из нескольких элементов, указывает на индекс элемента в работе. По умолчанию 0 Листинг 3.2. Пример программы - пиксельного шейдера на HLSL // объявим текстурную переменную, // которой в приложении будет присвоено // значение объекта D3DTexture9 texture ТехО // опишем сэмплер. // Это объявление указывает DirectX на то, // каким образом мы хотим использовать
ЯЗЫК ОПИСАНИЯ ШЕЙДЕРОВ hlsl 59 // свою текстуру в пиксельном шейдере, sampler MySampler = sampler_state { Texture = (TexO); MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR; // все прочие переменные // сохраняют значения по умолчанию }; // пиксельный шейдер на HLSL. // получим тексель входной текстуры, // используя ее координаты и описанный выше // сэмплер, вернем цвет в качестве результата float4 PS( float2 TexCoords : TEXCOORDO) : COLOR { return tex2D(MySampler, TexCoords ); } HLSL поддерживает четыре типа сэмплеров: samplerlD, sampler2D, sampler3D и samplerCUBE. Каждый из них создан для работы с текстурой того или иного вида. Помимо этого, компилятор HLSL не отвергает и базовый термин сэмплер (sampler), который применяется в листинге 3.2 и автоматически отображается в один из четырех настоящих типов шаблона с учетом реально используемых текстуры и метода выборки. Далее шаблон служит для того, чтобы в пиксельном шейдере произвести выборку нуж- ного текселя4 (texel) из имеющейся цепочки мипмэпов. Реальная выборка осуществляется при помощи встроенных функций сэмплирования текстур, входящих в состав HLSL. В нашем примере элемент текстуры выбирается с применением двухмерной функции tex2D. Дополнительные функции сэмплирования текстур предназначаются для одно-, трех- мерного, а также проекционного (projection sampling) метода выборки. Полный перечень текстурных шаблонов приведен в списке встроенных функций HLSL в тексте приложения С. Результат работы нашего учебного шейдера состоит в том, что содержимое текстуры, адресуемой как техО, будет сэмплировано с учетом текстурных координат, переданных функции PS. Для каждого запрошенного образца (сэмпла) мы возвращаем четырехком- понентный вектор, который содержит цвет сэмплированного элемента текстуры в форма- те RGBA. Приписанная шейдерной функции семантика COLOR позволит выдать значе- ние RGBA в виде объекта float4, представляющего окончательный результат работы пиксельного шейдера. 4 Единичный элемент текстуры. - Примеч. науч. ред.
60 Глава 3 Шейдеры процедурных текстур Применение пиксельных шейдеров не ограничено расчетом значений для решения задач визуализации. Функции тех же шейдеров могут служить и для создания про- цедурных текстур, которые затем могут загружаться для их использования в дальнейшем. Для этого пиксельный шейдер компилируется и дополняется одной из функций рен- деринга текстур, входящих в библиотеку D3DX (D3DXFillTextureTX, D3DXFill- VolumeTextureTX и D3DXFillCubeTextureTX). Эти функции инициируют вызов шейдера один раз для каждого текселя изображения, что позволяет, пользуясь им, сформировать в результате итоговую текстуру. Входные параметры этих пиксельных шейдеров не идентичны тем, что применялись для рендеринга геометрии модели. Шейдеры процедурных текстур должны соответство- вать особым шаблонам определения функций, совместимым с используемой функцией заполнения текстуры. Так, D3DXFillTextureTX может вызываться лишь для работы с шейдером процедурных текстур, входом которого являются координаты текстуры на плоскости. Пиксельный шейдер должен использовать эти данные для расчета оконча- тельного значения цвета в виде вектора из четырех чисел с плавающей запятой, который будет вписан в текстуру. Работа на унаследованных системах Создание HLSL-шейдеров дает программисту определенную степень свободы. Шей- деры, написанные на HLSL, можно, в разумных пределах, откомпилировать для всех аппаратных платформ с поддержкой программируемых шейдеров DirectX. Однако более старые видеокарты были нацелены на работу с вершинными и пиксельными шейдерами, отвечавшими их собственным спецификациям. Ранние спецификации шейдеров имели очень ограниченный набор выполняемых операций, объем временной и постоянной реги- стровой памяти и отличались малым числом операций в расчете на один шейдер. Построение HLSL-шейдеров для этой аппаратуры требует больше внимания и пред- полагает знание тех операций низкого уровня, которые реализуют встроенные функции языка. Неоценимую работу по созданию шейдеров, совместимых со старыми специфика- циями, проделывает компилятор HLSL, однако дополнительные инструкции, необходи- мые для эмуляции последних методов работы вершинных и пиксельных шейдеров, часто приводят к превышению предельного числа команд, определенного старой специфика- цией, или получению кода, который работает слишком медленно и не годится для прило- жений реального времени. Проектируя HLSL-шейдеры для унаследованных систем, вы будете получать от ком- пилятора коды ошибок, которые предупредят вас о превышении предельного количества инструкций или попытках использовать неподдерживаемые возможности языка.
ЯЗЫК ОПИСАНИЯ ШЕЙДЕРОВ hlsl 61 Все функции D3DX, занятые в компиляции шейдеров (D3DXCreateEf f ect, D3DXCompileShader и т. д.), могут помещать строки ошибок в буфер D3DXBUFFER, если он есть. Большую помощь в построении HLSL-шейдеров для устаревших платформ может оказать мониторинг сообщений о подобных ошибках. Другим полезным инструментом является компилятор с интерфейсом командной строки, входящий в состав DirectX SDK. Находящаяся в папке SDK \Bin\DXUtils программа fxc.exe может компилировать файлы HLSL-шейдеров и выдавать объектные файлы эффектов. Одной из самых актуальных возможностей этого автономного компиля- тора является его способность генерировать обычные инструкции ассемблера шейдеров на основе HLSL-кода. Для этого служит опция компилятора /Fc. Компилируя HLSL-шей- деры с этой опцией и наблюдая за выдаваемой информацией, вы можете неплохо разобраться в том, какие встроенные функции HLSL не находят должной реализации в старых спецификациях шейдеров. HLSL-функции в файлах эффектов Функции HLSL могут быть встроены непосредственно в файлы эффектов Direct3D. Данный подход означает, что в файле сосредоточено все описание применяемой методики рендеринга, а это упрощает создание прототипов и поддержку готового результата. Способность объединять в файле эффектов ряд вариантов реализации указывает на то, что мы, как было сказано в главе 1, можем сводить в один файл множество версий подпро- грамм на HLSL и применять метод отбора для отыскания лучшего варианта, который и будет запущен на данной платформе. Чтобы вершинные и пиксельные шейдеры связать с вариантом реализации, доста- точно поместить их в присутствующий в тексте реализации объект vertexshader или pixelshader, а также воспользоваться объявлением спецификации языка при компиля- ции подпрограммы. Функции на языке HLSL можно писать как в самой реализации, так и используя ссылки на внешние функции. Листинг 3.3 содержит два представленных нами выше примера шейдеров, объединенных в одном файле эффектов. Приведенный в конце файла вариант реализации связывает пиксельные шейдеры и валидацию. ЛИСТИНГ 3.3. Пример файла эффектов Direct3D с вершинным и пиксельным шейдером на языке HLSL // // Пример простого шейдера на языке HLSL. // // Этот вариант предполагает работу с одной // текстурой для рендеринга 30-модели. // // Замечание. Данный эффект предназначен для
62 Глава 3 // работы с программой EffectEdit, поставляемой // в составе DirectX SDK. // // данные пользователя string XFile = "tiger.х"; // модель int BCLR = 0xff202080; // фон // текстура texture ТехО < string name = "tiger.bmp"; >; // преобразования float4x4 WorldMatrix : WORLD; float4x4 ViewMatrix : VIEW; float4x4 ProjectionMatrix : PROJECTION; // определение данных, создаваемых // нашим вершинным шейдером struct VS_OUTPUT { float4 Pos : POSITION; float2 Tex : TEXCOORDO; }; // код вершинного шейдера на HLSL VS_OUTPUT VS( float3 Pos : POSITION, float2 Tex : TEXCOORDO) { // опишем выходную структуру VS_OUTPUT Out = (VS_OUTPUT)0; // построим конкатенацию матриц преобразований // мирового пространства и вида float4x4 Worldview = mul( WorldMatrix, ViewMatrix); // перенесем вершину из пространства объекта // в пространство вида и сохраним ее в Р floats Р = mul( float4(Pos, 1), (float4x3)Worldview); // используя матрицу проекций, // спроецируем положение экрана. // результат поместим непосредственно // в выходную структуру Out.Pos = mul(float4(P, 1), ProjectionMatrix);
ЯЗЫК ОПИСАНИЯ ШЕЙДЕРОВ hlsl 63 // кроме того, скопируем в выходную структуру // UV-координаты текстуры Out.Тех = Тех; return Out; } // опишем шаблон. // Это объявление указывает DirectX на то, // каким образом мы хотим использовать // свою текстуру в пиксельном шейдере, sampler Sampler = sampler_state { Texture = (TexO); MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR; }; // пиксельный шейдер на HLSL. // получим образец текселя входной текстуры, // используя ее координаты и описанный выше // сэмплер, вернем цвет в качестве результата float* PS( floats Тех : TEXCOORDO) : COLOR { return tex2D(Sampler, Tex); } // итоговый вариант реализации содержит лишь созданные III нами вершинный и пиксельный шейдеры. Если бы нам // понадобились дополнительные структуры рендеринга, //мы могли бы их здесь добавить technique SimpleShader { pass РО { // шейдеры VertexShader = compile vs_l_l VS(); PixelShader = compile ps_l_l PS(); ) } В этом примере шейдера используется чуть больше объявлений, выполненных самим пользователем. Это сделано для обеспечения совместимости шейдера с программой EffectEdit в составе DirectX SDK. Программа EffectEdit реализует синтаксический разбор этих строк-объявлений и выполняет такие задачи, как загрузка необходимых
64 Глава 3 текстур и установка фонового цвета окна предварительного просмотра. Далее, она может произвести просмотр нашего шейдера и сообщить обо всех ошибках, выдав их в своем собственном окне вывода. Просмотр представленного в листинге 3.3 примера файла эффектов в среде Ef fectEdit проиллюстрирован рис. 3.1. Рис. 3.1. Представленный в листинге 3.3 примет файла эффектов запущен в утилите EffectEdit в составе DirectX 9.0 SDK
Глава 4 ОБЗОР ДВИЖКА Gaia В первых трех главах мы познакомились с отдельными компонентами DirectX, которыми будем пользоваться для упрощения разработки нашего движка: библиотекой Direct3D Sample Framework, библиотекой расширений Direct3D и новым языком описания шейдеров HLSL. В последней главе этой части мы завершим введение в процесс создания движка рассказом о ряде ключевых типов ресурсов, которые в нем используются. По мере чтения файлов исход- ного кода вы будете часто встречаться с этими классами, поэтому в этой главе мы уделим им немного внимания, представив краткий обзор самих классов и их возможностей. Не беспокойтесь, если первые шаги разработки покажутся вам скучноватыми - они такие и есть. Однако эти недолгие минуты, проведенные сейчас «на раскопках», окупятся чуть позднее, когда мы перейдем к более приятным занятиям. Эти простые классы и интерфейсы сэкономят нам время и станут залогом того, что фрагменты движка, которые мы напишем в дальнейшем, будут иметь общую согласованную структуру. Начнем же мы с представления движка и той практики написания кода, которая использовалась при его построении, после чего перейдем к отдельным специальным типам ресурсов, которые мы создадим. Лучший способ близкого знакомства с ресурсами тех или иных типов - обычный про- смотр представленных файлов с исходным кодом. В этой главе мы предложим вам краткое введение с описанием каждого типа ресурсов, а также существенных моментов органи- зации самих классов. Конкретные детали реализации смотрите непосредственно в файлах исходного кода. Встречайте, Gaia - движок генерации ландшафтов Наш движок ЗО-игр на открытом пространстве мы будем называть «Gaia», по имени греческой богини земли (в конце концов, это же генератор ландшафта) Выбор имени для движка полезен в плане логического разделения нашего исходного кода и кода сопровож- дающих его ОЗОХ-библиотек, а также возможности физического разделения определений наших собственных типов и определений во внешних библиотеках. 3 - 1839
66 Глава 4 Многие игровые движки прибегают к особым библиотекам сторонних производите- лей для решения таких специфичных задач, как управление воспроизведением 3 D-звука или физическая симуляция. Чтобы это мероприятие удалось, требуется принять меры для того, чтобы основной код движка не конфликтовал с внешним исходным кодом или вспо- могательными библиотеками. Простейший подход к решению этой задачи состоит в том, чтобы поместить весь базовый код движка в некое пространство имен, которое и защитит объявление любого класса, типа или функции от внешнего мира. Это может повергнуть в уныние некоторых программистов, не привыкших к пространствам имен, однако поль- зоваться ими гораздо лучше, чем добавлять к каждому описанию функции или типа тот или иной префикс для идентификации этого имени в тексте кода. Отказ применять пространства имен в пользу именования функций движка с префик- сом gaia, например gaiaDrawBox() и gaiaPrintText(), может показаться огромным соблаз- ном, однако этот тип избыточной идентификации очень быстро становится обременитель- ным. Во избежание каких бы то ни было коллизий между именами в объявлениях типов движка и объявлениях типов другого автора префикс gaia пришлось бы добавлять в каждый объявленный класс, тип данных и функцию. Вместо применения префиксов для изоляции всего основного кода движка можно вос- пользоваться единственным пространством имен. В нем будут объявлены все глобальные функции и описания классов. Фактически это эмулирует префикс gaia ценой нескольких сокращений, которыми и ограничится число мест, где необходимо его явное указание. Это связано с тем, что все классы и функции, объявленные в пространстве имен gaia, естественно, будут использовать его по умолчанию, избавив программиста от необходи- мости явно указывать пространство имен для функций и тйпов, используемых внутри класса. Кроме того, в начале всех файлов с исходным кодом движка может быть сделано объявление using namespace gaia, которое проинформирует компилятор о том, что пространством имен по умолчанию должно стать gaia, и это опять же устранит потреб- ность в явном наборе префикса. Для опытных программистов на языке C++ такое применение пространства имен для1 изоляции кода библиотеки не представляет ничего нового. Библиотека Standard Template* Library (STL) делает то же самое, помещая все шаблоны своих библиотек в пространство имен std. Если это решение вполне приемлемо для STL - набора детально описанных служебных классов, используемых миллионами программистов, оно приемлемо и для нас. Далее, на протяжении этой книги мы будем предполагать применение пространства имен gaia. При этом, чтобы слегка упростить примеры исходного кода, мы будем опускать пространство имен в листингах, которые приводятся в тексте.
ОБЗОР ДВИЖКА Gaia 67 Главное приложение Чтобы организовать наш собственный уровень для контроля программы, построим свой класс, пользуясь той основой, которую предоставляет структура Direct3D. Базой нам послужит класс CD3DApplication, позволяющий использовать существующие возмож- ности инициализации, создания перечня видеоустройств, настройки экранных режимов и обработки стандартных сообщений от окон. Поверх этого класса мы создадим свои соб- ственные менеджеры ресурсов и устройств, будем контролировать выполнение нашей программы и осуществим рендеринг своего мира. Все это мы сделаем, создав свой класс главного приложения cGameHost. Класс cGameHost послужит для решения сразу нескольких задач. Прежде всего, он станет центральным объектом нашего движка в целом. При построении своих классов мы выберем подход на основе объектов-менеджеров и создадим особые классы с единствен- ным экземпляром для контроля над ключевыми аспектами работы движка. Созданные менеджеры смогут, например, управлять такими вещами, как конвейер рендеринга, поль- зовательский ввод и т. д. Эти самодостаточные управляющие объекты потребуют наличия общего подпроцесса как средства доступа к ним и коммуникации с ними со стороны нашей программы. Класс cGameHost станет контейнером всех объектов-менеджеров и предоставит методы доступа, предназначенные для получения их интерфейсов. Чтобы построить подобный класс, воспользуемся методом объявления единственного экземпляра, который гарантирует, что будет создан единичный класс cGameHost, а прило- жение получит глобальный доступ к его интерфейсу. Базовый класс с единственным экземпляром детально описан в приложении А «Служебные классы Gaia». По соображе- ниям гибкости кода метод единственного экземпляра предполагает порождение классов. Если игровой программе нужно придать специальные возможности, то для выполнения дополнительных действий от cGameHost может быть порожден особый объект главного приложения. Самой полезной стороной метода объявления единственного экземпляра является то, что объект cGameHost будет обеспечивать доступ к используемым движком объектам-менеджерам из любой точки программы, включая сами эти объекты. Наряду с функцией коммутатора для общения с менеджерами устройств класс cGame- Host служит ядром приложения. Он представляет собой оболочку главной программы, где происходят все аспекты взаимодействия с операционной системой. В нашем случае это означает, что окно на рабочем столе с запущенной в нем игрой поддерживает объект cGameHost, а все сообщения, посылаемые этому окну, обрабатывает указанный класс. Эти возможности мы без труда получим, породив свой класс от класса CD3DApplication, содержащегося в библиотеке Direct3D. Порождение нашего класса от такого родителя дает возможность принять на себя управление различными аспектами работы нашей программы.
68 Глава 4 Для начала мы будем обрабатывать обновление нашего игрового мира немного не так, как это делают примеры из DirectX. Последние поддерживают однозначное соотношение между обновлением состояния своего мира и отображением каждого кадра. При каждом проходе производится обновление состояния всего приложения и осуществляется рен- деринг нового кадра. Эти проходы, связанные с обновлением мира, происходят неуправ- ляемым образом, а значит, для выяснения того, насколько объект должен быть передвинут или как он должен быть анимирован во время каждого обновления, должны использо- ваться значения в реальном масштабе времени. Мы же выбираем подход, в центре которого скорее стоит игра, и делим время на ряд дискретных шагов, которые называем тиками (ticks). Каждый тик представляет фиксиро- ванный временной интервал. Поэтому всякий раз при обработке тика мы точно знаем, сколько времени истекло. Этот подход с фиксированным временным шагом освобождает нас от необходимости следить за часами реального времени и выполнять более сложные обновления состояния нашего мира в режиме переменного времени. Очевидная претензия к данному подходу состоит в том, что он не дает возможности делать анимацию такой же гладкой, как при непрерывном подходе, использующем пере- менное время. Такое беспокойство оправданно. Если мы обновляем свой игровой мир лишь в определенные промежутки времени, наша анимация никогда не станет более глад- кой, чем это позволяет временной интервал. Однако метод с фиксированным шагом не требует, чтобы использовался только один такой шаг, и мы оставляем за собой право определить несколько типов связанных с обновлением тиков. Так, состояние игры мы можем обновлять с одной частотой, а анимацию - с другой, более высокой скоростью. Это позволит нам упростить работу конечных автоматов или логических операций, которые должны быть выполнены для обновления нашего мира, на фоне более гладкой, детализированной анимации. В нашем примере движка мы ограничим длительность каждого тика '/30 секунды, или примерно 33 миллисекун- дами. Обновление анимации мы будем производить с той же скоростью, однако оставим возможность для перехода к более высокой частоте анимации, если того захотим. Наш подход с временным шагом реализует перегруженная функция FrameMove, член класса CD3Dappli cat ion, представленная в листинге 4.1. Сюда же мы добавим несколько функций управления подпроцессами, необходимых для мирного сосуществования с другими задачами Windows. Наше приложение будет бездействовать до очередного тика часов реального времени. При наступлении этого момента мы обновим состояние нашей игры и вернемся к бездействию. Если предполагать, что обновление и обработка мира не занимают больше одного тика реального времени, то приложение чаще всего будет простаивать. Понизив приоритет нашего подпроцесса и отказавшись использовать оста- ток своего кванта времени после обработки каждого тика, мы дадим другим подпроцессам возможность произвести свое обновление.
ОБЗОР ДВИЖКА Gaia 71 Размер такого массива должен быть известен до запроса его первого члена, и он не сможет стать больше начального количества размещенных в памяти элементов, что ограничит общее число объектов, которые можно использовать в процессе игры. Жесткая схема массива обеспечивает оптимальный расход памяти за счет ограничения числа объектов, которые могут храниться в нем. Этот подход идеален для приложения, в котором количество всех объектов известно, а они сами могут быть действительно заранее размещены в памяти, наша же разработка нужда- ется в чуть большей гибкости Решение, которое используется в движке для размещения пулов данных, а именно шаблонный класс cDataPool, фактически не содержит никакого массива объектов-чле- нов. Вместо этого в нем находится связанный список массивов объектов-членов cPool- Group. Каждая из этих групп содержит постоянное количество объектов и может добав- ляться или удаляться из пула, если это необходимо. Тем самым достигается компромисс между эффективным расходом памяти и переменной численностью объектов. Хотя пул в случае необходимости может расти, его рост происходит лишь заранее определенными «порциями», каждая из которых вмещает в себя память под блок новых членов. Важнейшим плюсом такой организации является то, что, не затрагивая всей осталь- ной игры, пул можно сделать обычным массивом. Созданное внутри пула вложенное дерево объектов по-прежнему дает уникальный индекс каждого его члена. Если объекты, расположенные в пределах дерева пула данных, были свернуты в обычный массив, значе- ния их индексов останутся без изменений. На рис. 4.1 бок о бок показана пара структур данных, сравнение методов индексации которых демонстрирует их подобие. Это свойст- во, которое имеет пул данных, позволяет работать с его более гибким, хотя и менее эффек- тивным представлением, зная, что позднее, когда все наши затраты памяти будут извест- ны, мы сможем заменить его на эффективное распределение данных в виде простого мас- сива. Что же касается игры, то индексы, которые используются как дескрипторы объектов в пределах пула, сохраняют свои прежние значения. Применение методик хранения как на основе дерева cDataPool, так и на основе простого массива предполагает, что те или иные участки памяти будут использоваться повторно. Когда члены пула освобождаются, они становятся доступными для будущих вызывающих подпрограмм. Поэтому нам требуется отслеживать, какие из членов пула дос- тупны, чтобы при поступлении запроса быстро отыскать свободную область. В каждом из объектов cPoolGroup ведется связанный список свободных членов. Это обычный массив слов, значения которых равны номерам членов группы. Каждое значение в массиве указывает, занят или свободен соответствующий ему член пула данных.
70 Глава 4 // до истечения полного тика таймера, а потому откажемся от // остатка того кванта времени, который был выделен для нас // Windows, и дадим возможность выполнения других подпроцессов // в состоянии ожидания. Это предотвратит захват 100% времени // CPU в ожидании окончания интервала. Sleep(0); } return S_OK; } Одним из ключевых объектов, содержащихся в cGameHost, является менеджер ресурсов. Наше главное приложение будет иметь собственную систему управления ресурсами для работы с объектами устройств, которые мы создадим. Вершина этой систе- мы ресурсов - класс cResourcePoolManager, основанный на множестве классов для работы с пулами данных, являющихся частью ядра. Прежде чем мы обсудим сами объекты ресурсов, выясним вкратце, что же такое пул данных, которым мы будем пользо- ваться, и какова схема управления ресурсами, которая придет на смену стандартной. Создание пулов данных Один из основных аспектов эффективного управления памятью - ограничение общего числа операций ее выделения. Простейший способ добиться этого состоит в объединении схожих объектов в группы для выделения большего объема пространства. Мы будем назы- вать такие группы пулами данных (data pools) и использовать их как контейнеры для мно- жественных объектов. Объекты, которые находятся в пуле, считаются его членами и по запросу передаются вызывающей подпрограмме при помощи дескрипторов (handles). Члены пула могут запрашиваться и освобождаться клиентами. При необходимости пул может расти, увеличиваясь в размере на фиксированный шаг инкремента для размещения в нем большего числа членов. Именно здесь код может быть слегка свернут. Мы хотим, чтобы пулы имели ту или иную степень типовой безопасности, а потому строим их как шаблонные классы - с применением конкретного типа данных для представления членов пула. Однако, помимо этого, мы должны со всеми пулами общаться по единому интерфейсу. Если позднее мы создадим менеджер ресурсов для поддержки пулов текстур, вершин и анимации, общение с различными пулами данных по единому интерфейсу будет жизненно необходимо для создания и уничтожения этих ресурсов. Поэтому, хотя сами пулы данных и являются отдельными реализациями шаб- лонного класса, они должны порождаться от единого интерфейса, которым внешние менед- жеры могут пользоваться для создания и освобождения членов пулов. Разместить в памяти большой массив из объектов и ссылаться на них по их индексам в этом массиве было бы совершенно разумно, но данный подход, к сожалению, лишен гибкости.
ОБЗОР ДВИЖКА Gaia 69 В отсутствие этих проверок для подпроцессов наша программа будет всегда использо- вать 100% доступного процессорного времени. Это может вести к накоплению других ожидающих выполнения подпроцессов и создавать ненужную нагрузку на операционную систему, которая стремится сгладить выполнение каждого подпроцесса. Принятие таких мер поможет упростить эффективный менеджмент подпроцессов в операционной системе и гарантирует, что мы получим желаемый приоритет при выполнении в то время, когда нам это надо больше всего. ЛИСТИНГ 4.1. Управление временем и приоритетами подпроцесса в классе cGameHost HRESULT cGameHost::FrameMove() { static HANDLE hThread = GetCurrentThread(); // сложим истекшее время по часам реального времени // с содержимым внутреннего счетчика-задержки m_updateTimeCount += m_fElapsedTime; // обновление уже готово произойти? if (m_updateTimeCount > k_millisecondsPerTick) { // повысим приоритет для активизации подпроцесса // при обработке тика часов в игре SetThreadPriority( hThread, THREAD_PRIORITY_TIME_CRITICAL); // выполним нужное число обновлений while(m_updateTimeCount > k_millisecondsPerTick) { HRESULT result; // обновим сцену if(FAILED! result = updateScene())) return result; // вычтем эмулируемый интервал // времени из каждого тика m_updateTimeCount -= k_.mil lisecondsPerTick; 1 // вернемся к нормальному приоритету // в ожидании нового тика SetThreadPriority( hThread, THREAD_PRIORITY_NORMAL); // мы знаем, что очередного запуска нашего кода не произойдет
72 Глава 4 Рис. 4.1. Сравнение схемы дерева cDataPool и простого массива Каждый используемый член характеризуется константой (INVALID_INDEX), храня- щейся в этом массиве и свидетельствующей о том, что данный член недоступен. Свобод- ные члены пула образуют связанный список индексов. Каждый член содержит индекс очередного свободного элемента списка. Индекс первого свободного элемента включает в себя наш класс cDataPool, последний свободный элемент содержит свой собственный йндекс, что является признаком конца данной цепи. Произвести возврат свободного эле- мента вызывающей подпрограмме мы можем, просто вернув ей первый свободный индекс и перенеся свой внутренний дескриптор на следующий свободный (если он есть). Как только вызывающая подпрограмма освободит член пула, мы установим наш внутренний дескриптор непосредственно на возвращенный в пул элемент, в котором, в свою очередь, сохраним прежний головной элемент списка свободных членов. с Этот процесс гораздо проще понять, глядя в исходный код. Полный исходный ------' код объектов для работы с пулами данных можно найти в файле source_code\core\data_pool. h на прилагаемом компакт-диске. В целом возможности этих объектов слишком сложны, чтобы приводить в книге их полный листинг, однако функции, отвечающие за добавление и удаление элементов из пула, вы можете найти в листинге 4.2. В этих примерах приведены те основные операции, которые необходимы для отыскания доступного объекта cPoolGroup в составе пула, извлечения из него сво- бодного индекса и построения объекта cPoolHandle для работы с вызывающей подпро- граммой, содержащей значение индекса. Для освобождения элемента он локализуется внутри пула и помещается в голову свободного списка узлов.
ОБЗОР ДВИЖКА Gaia 73 ЛИСТИНГ 4.2. Функции добавления членов в пул данных и удаления их из него #define INVALID_INDEX Oxffff #define CLEAR_HANDLE(h) (h=INVALID_INDEX) // // извлечем из пула новый дескриптор, // увеличив размер пула, если это необходимо // template <class Т> inline cPoolHandle cDataPool<T>::nextHandle() { debug_assert(islnitialized() , "the cDataPool is not initialized"); // "cDataPool не инициализирован" // найдем или создадим группу, имеющую доступную область unsigned groupNumber=0; cPoolGroup<T>* openGroup = findOpenGroup(kgroupNumber); // найдем первую свободную область в группе int index = openGroup->nextMember(); --m_totalOpen; // построим дескриптор, который будет передан как результат функции return buildHandle(groupNumber, index); } // // вернем член в пул данных // template <class Т> inline void cDataPool<T>::release(cPoolHandle* pHandle) { debug_assert(isInitial!zed(), "the cDataPool is not initialized"); // "cDataPool не инициа- лизирован" debug_assert(pHandle, "A valid handle must be provided"); // "Требуется правильный дескриптор" if (isHandleValid(*pHandle)) { debug_assert(m_groupList.size(), "The cDataPool has not been properly created"); // "cDataPool не был создан корректно" // разделим дескриптор на номер // группы и значение индекса
74 Глава 4 int grouplndex = getGroupNumber(*pHandle); int itemindex = getltemlndex(*pHandle); cPoolGroup<T>* group = getGroup(grouplndex); // сообщим группе об освобождении ее члена group->release(itemindex); // очистим дескриптор вызывающей подпрограммы CLEAR_HANDLE(*pHandle); // проверим, можем ли мы удалить последнюю группу cPoolGroup<T>* pGroup=m_groupList.back(); if (pGroup->totalOpen() == m_groupCount) { pGroup->destroy(); delete pGroup; m_groupList.pop_back(); } ++m_totalOpen; } } template <class T> inline cPoolGroup<T>* cDataPool<T>::findOpenGroup(unsigned* groupNumber) { // найдем и вернем первую группу, имеющую свободную область *groupNumber = 0; for (MemberGroupList::iterator iter = m_groupList.begin() ; iter != m_groupList.end(); ++iter) { cPoolGroup<T>* pGroup = *iter; if (pGroup->totalOpen()) { // свободная область найдена return(pGroup); } + + ( *groupNumber); } // свободных областей нет, поэтому // мы должны добавить новый объект cPoolGroup; // прежде чем создавать новую группу, убедимся // в том, что мы не достигли максимума в // MAX-UINT16 членов
ОБЗОР ДВИЖКА Gaia 75 debug_assert(m_groupList.size()*(m_groupCount+l) < (uintl6)MAX_UINT16, "the cDataPool is full I I ! !") ; // "cDataPool полон! ! ! !" // добавим новую группу return(addGroup()); } template cclass T> inline cPoolGroup<T>* cDataPool<T>::addGroup() { // добавим новую группу в список cPoolGroup<T>* pNewGroup = new cPoolGroup<T>(m_groupCount); m_groupList.insert(m_groupList.end(), pNewGroup); // получим доступ к новой группе и инициализируем ее cPoolGroup<T>* pGroup = m_groupList.back(); pGroup->create(); // увеличим значения внутренних счетчиков nLtotalMembers += m_groupCount; m_totalOpen += m_groupCount; II вернем указатель на новую группу return(pGroup); } template <class T> inline uintl6 cPoolGroup<T>:mextMember() { debug_assert(m_memberList && m_nextOpenList, "Group has not been created"); // "Группа не создана" debug_assert(m_totalOpen, "no open slots"); // вернем первый элемент списка // свободных членов и передвинем внутренний // дескриптор на следующий элемент uintl6 slot = m_firstOpen; m_firstOpen = m_nextOpenList[slot] ; --m_totalOpen; debug_assert(m_firstOpen != INVALID_INDEX, "Invalid Open Index"); // "Неверный свободный индекс" debug_assert(isOpen(slot), "invalid index"); // "неверный индекс" // пометим данный элемент как используемый m_nextOpenList[slot] = INVALID_INDEX; return(slot);
76 Глава 4 Управление разделяемыми ресурсами данных Наш движок имеет несколько видов совместно используемых ресурсов. Они представ- ляют собой данные, к которым могут обращаться и которые могут коллективно исполь- зовать многие игровые объекты. К примеру, это текстуры, буферы вершин, индексов и процедуры рендеринга. Фактически многие разделяемые ресурсы зависят от аппаратной видеоподсистемы, так как частично могут хранить свои данные в резидентной памяти аппаратуры. Менеджерам этих устройств необходим доступ ко всем ресурсам, которые от них зависят. Так, экранный менеджер требует доступа ко всем текстурам, буферам индексов и вершин, чтобы иметь возможность обрабатывать случаи потери и восстанов- ления интерфейса с видеоустройством. Для этого необходимо нескольких ключевых классов. Во-первых, опишем базовый класс для работы со всеми разделяемыми ресурсами cResourcePoolItem, который обес- печит единый интерфейс, коим смогут пользоваться все внешние вызывающие подпро- граммы. В него войдут базовые интерфейсные функции создания и уничтожения ресурса, установки и снятия запрета на его применение, а также потоковой записи в файл и чтения его с диска. В базовом классе эти функции-члены представлены как чистые виртуальные функции, что вынуждает все классы, порожденные от cResourcePoolItem, предложить фактическую реализацию этих членов исходя из реально хранящихся данных. Этот вирту- альный интерфейс позволит внешней вызывающей подпрограмме, такой, как менеджер устройства, воздействовать на целый ряд объектов-потомков cResourcePoolItem, не имея информации о том, данные какого именно типа они представляют. Руководствуясь теми же соображениями, добавим класс cResourcePool. Он станет расширением класса cDataPool, предназначенным для хранения объектов, основанных на cResourcePoolItem, и реализации дополнительных методов работы с такими ресурсами. Как и cResourcePoolItem, класс cResourcePool использует единый базо- вый интерфейс, доступный для внешних вызывающих подпрограмм. Если все объекты cResourcePoolItem тоже содержат стандартный набор методов интерфейса, то класс cResourcePool вводит функции-члены для организации циклов по всем членам пула и передачи данных функциям циклической обработки. Внешним подпрограммам этот класс дает возможность массовой обработки совместно используемых типов ресурсов. Менеджер видеокарты может, например, использовать этот тип интерфейса для быстрой установки или снятия блокировки со всех объектов-текстур в пределах cResourcePool. Разделяемые ресурсы часто идентифицируются строкой текста. Так, для текстур эта строка может содержать фактический путь к исходному изображению на диске. Помимо поддержки операций массовой обработки, cResourcePool содержит спра- вочную таблицу таких строк с именами ресурсов, что позволяет вызывающим подпро- граммам осуществлять поиск совместно используемых объектов-ресурсов по именам. Для построения такой «записной книжки» с именами ресурсов и предоставления ускорен-
ОБЗОР ДВИЖКА Gaia ных методов поиска для отыскания отдельных элементов в составе пула используется класс-контейнер отображения из библиотеки Standard Template Library (STL). Наконец, единственный экземпляр класса cResgurcePoolManager реализует центральное хранилище всех пулов совместно используемых ресурсов. Этот класс может создавать и регистрировать отдельные объекты cResourcePool, а также устанавливать значения индексов для идентификации типов ресурсов в пуле и семейства, которому данные ресурсы принадлежат. Эти индексные значения собраны в глобальных типах- перечислениях, находящихся в заголовочном файле cResourcePoolManager. Одно из перечислений определяет всевозможные семейства ресурсов (аудио, видео и т. д.). Другие перечисления идентифицируют отдельные типы ресурсов каждого из семейств. // семейства ресурсов епшп RESOURCE_FAMILY { k_nVideoResource=0, k_nAudioResource, k_nGameResource, //. . .и т. д. k_nTotalResourceFamilies }; // члены семейства видеоресурсов... enum VIDEO_RESOURCES { k_nTextureResource=0, k_nVBufferResource, k_nIBufferResource, k_nRenderResource, // . . .и т. д. k_nTotaiVideoResources }; Индексы семейства и типа ресурсов занимают по 16 бит каждый и совместно хранятся как единое 32-разрядное значение. Это значение, именуемое cResourceCode, служит для однозначной идентификации всех классов объектов-ресурсов, которыми управляет менеджер. При создании новых пулов они регистрируют себя, предоставляя менеджеру значение cResourceCode для самоидентификации. После этого внешние подпрограммы могут обращаться к интерфейсам cResourcePool, запросив их из менеджера по харак- терному коду cResourceCode. Функциональность классов ресурсов станет более очевидной, когда мы построим реальные ресурсы движка и объекты управления, которые их будут
78 Глава 4 использовать. Пока же для более детального изучения возможностей этих классов обрати- тесь к нужным вам файлам исходного кода на прилагаемом компакт-диске. Список файлов с исходным кодом ресурсов приведен в начале этой главы. В листинге 4.3 показан пример создания и применения объектов-ресурсов. ЛИСТИНГ 4.3. Пример кода как иллюстрация применения классов для работы с ресурсами // пусть выбран класс ресурса "текстура" class cTexture : public cResourcePoolItem { }; // и пул ресурсов для поддержки и управления им typedef cResourcePool<cTexture> cTexturePool; // // следующий код служит примером того, // как их можно использовать // void resource_setup() { // создадим пул текстур cTexturePool* myTexturePool = new cTexturePool; // и зарегистрируем его с участием // единственного менеджера ресурсов, укажем // номер семейства и значение типа ресурса ResourceManager.registerResourcePool( cResourceCode(k_nVideoResource, k_nTextureResource), cResourcePoollnterface*) myTexturePool); // теперь мы можем создать несколько пробных текстур myTexturePool.createResource("texture О"); myTexturePool.createResource("texture 1"); myTexturePool.createResource("texture 2"); } void resource_cleanup() { // потребуем от менеджера ресурсов уничтожения // всех ресурсов устройства отображения ResourceManager.destroyResourceFamily( k_nVideoResource); // отменим регистрацию пула текстурных ресурсов
ОБЗОР ДВИЖКА Gaia 79 cResourcePoolInterface* pTexturePool = ResourceManager.unregisterResourcePool( cResourceCode(k_nVideoResource, k_nTextureResource)) ; // и уничтожим его delete pTexturePool; } void resource_sample { resource_setup (); // найдем ресурс по его коду и имени cTexture* myTexture = ResourceManager.findResource( cResourceCode(k_nVideoResource, k_nTextureResource), "texture 0" // произведем с ресурсом какие-то действия if (myTexture) { myTexture->restoreResource(); // .. и т,д. } resource_cleanup (); } Базовый класс ресурсов Каждый относящийся к движку объект типа «ресурс» порожден от базового класса cResourcePoolltem. Этот класс предоставляет общий набор функций, используемых классами cResourcePoolManager для создания и уничтожения ресурсов, их сохранения на жесткий диск и чтения их с него. Объекты cResourcePoolltem поддерживают и ос- новной набор данных каждого объекта-ресурса, включая код ресурса, идентифи- цирующий тип последнего, а также интерфейс с менеджером ресурсов и cPoolHandle- индекс ресурса. Он же содержит битовое поле флагов, регистрирующих текущее состоя- ние объекта типа «ресурс». cResourcePoolltem предоставляет множество общих функций-членов для получе- ния имени ресурса, запроса его состояния и возврата дескриптора ресурсного пула. Важ- нейшие функции-члены, однако, те, что образуют серию чистых виртуальных функций для поддержки самих ресурсов. Ресурсы движка, порожденные от базового класса cResourcePoolltem, отвечают за эффективную реализацию этих чистых виртуальных
80 Глава 4 функций интерфейсного характера, описанных в cResourcePoolItem. Эти функции и образуют тот интерфейс, который используется управляющими объектами для манипу- ляций с ресурсами. Данные функции-члены приведены в листинге 4.4. Листинг 4.4. Виртуальные интерфейсные функции базового класса cResourcePoolItem // инициализировать ресурс (вызывается один раз) virtual bool createResource()=0; // уничтожить ресурс virtual bool destroyResource()=0; // удалить ресурс из энергозависимой памяти virtual bool disableResource()=0; // вернуть ресурс в энергозависимую память virtual bool restoreResource()=0; // загрузить ресурс из файла // (или NULL для работы с именем ресурса) virtual bool loadResource(const tchar* filename=0)=0; // сохранить ресурс в файле // (или NULL для работы с именем ресурса) virtual bool saveResource(const tchar* filename=0)=0; Пользуясь этим простым набором методов интерфейса, менеджер может полностью контролировать множество объектов-ресурсов. Самые примечательные интерфейсные функции disableResource и restoreResource позволяют удалять и возвращать ресурс в энергозависимую память. Примером тому могут быть текстуры, находящиеся в памяти видеокарты. Если интерфейс с видеоустройством потерян, менеджер ресурсов может быть извещен о необходимости блокировать все ресурсы, которые от него зависят. Когда связь восстановится, дочерние ресурсы могут быть с легкостью возвращены путем вызова для каждого из них соответствующих функций-членов. В библиотеке D3DX мы, к большому удовольствию, можем использовать встроенный менеджер ресурсов производства компании Microsoft. Работая с большинством наших ресурсов, зависимых от видеоподсистемы, мы будем пользоваться выгодой автоматического управления восстановлением резидентных видеоресурсов после временной потери устрой- ства. Настройка ресурсов на работу в этом режиме требует простого добавления флага D3DPOOL_MANAGED при создании поддерживающих автоматический менеджмент ресурсов Direct3D. Кроме того, мы выиграем и от автоматического управления памятью, которое предоставляет указанный флаг, гарантирующий то, что текстуры подгружаются и удаляются из видеопамяти по мере необходимости, когда объем памяти ограничен. Сказанное не относится к динамическим ресурсам, таким, как текстуры или гео- метрии, которые нуждаются в частом перерасчете. Такими ресурсами лучше управлять
ОБЗОР ДВИЖКА Gaia 81 вручную, а не средствами Direct3D, поскольку мы знаем, когда и как они должны заме- няться на новые данные. Наш интерфейс работы с ресурсами дает возможность использо- вать оба метода в зависимости от того, какой вариант перегрузки виртуальных функций для каждого описанного нами типа ресурсов мы изберем. Ресурсы-текстуры и материалы поверхностей Простейшим нашим ресурсом является объект cTexture. Фактически этот класс является оболочкой - объектом, содержащим другой объект - !Direct3DTexture9 в формате, совместимом с нашей схемой менеджмента ресурсов. Данный класс мы упомя- нули здесь лишь потому, что будем достаточно часто использовать его в ходе рендеринга моделей. Играя роль оболочки, класс cTexture также содержит функции загрузки файлов текстур с диска с применением методов библиотеки D3DX. С другой стороны, материалы поверхностей являются нашим собственным изобрете- нием и нуждаются в небольшом пояснении. D3DX страдает от наследия обратной совмес- тимости и все еще считает стандартной ситуацию, в которой на каждый материал поверх- ности приходится единственная текстура. Доказательством тому служит основная струк- тура для хранения таких объектов D3DXMATERIAL, которая предусматривает возможность задания только одной текстуры. Когда-то это было нормальным, однако при современном уровне продуктов лидеров видеоиндустрии такой подход чересчур устарел. Для своих нужд в плане рендеринга мы разработали улучшенный класс материала поверхности eSurf aceMaterial, который содержит до 16 текстур в дополнение к стан- дартным оттенкам диффузного, зеркального отражения и излучения. Хотя для сего- дняшних видеокарт, использующих пока лишь от 4 до 8 текстур за проход, работа с 16 текстурами может показаться избыточной, наличие дополнительного пространства дает возможность выделить место для размещения текстур для многократных проходов рендеринга одного материала поверхности. Например, мы могли бы так спроектировать материал поверхности, чтобы его рен- деринг осуществлялся за два прохода, каждый из которых требовал бы четырех уникаль- ных текстур. Восемь текстур прекрасно помещаются в заданных нами пределах и обла- дают уникальными индексами, которые могут использовать наши шейдеры HLSL. Кроме того, пространство хранилища мы можем использовать для размещения в нем нескольких вариантов материала поверхности, например летнего и зимнего варианта каждой тек- стуры. Шейдеры же могут содержать код инициализации, где производится выбор нуж- ного множества. При всех этих возможностях даже 16 текстур, наверное, слишком мало! Классы eSurfaceMaterial также содержат набор битовых флагов, по одному на каждую область текстуры. Эти 16 флагов указывают на то, загружена текстура в ту или иную область или же нет. Сейчас мы увидим, как битовые поля позволяют нам про- верять материалы поверхностей на соответствие тем методам рендеринга, в которых они будут использоваться.
82 Глава 4 Ресурсы методов рендеринга Класс cEffectFile является нашим контейнером для объекта iD3DXEffect. Помимо предоставления обычного набора функций менеджмента ресурсов и файлового ввода-вывода cEffectFile осуществляет разбор откомпилированных эффектов и нахо- дит в них переменные и константы, которые движок способен установить. Как мы говори- ли об этом в главе 2 «Основные объекты», файлы эффектов Direct3D могут содержать переменные с глобальной областью видимости с описаниями или семантикой, которая может быть найдена самим приложением. Оно может установить эти переменные, поль- зуясь интерфейсными функциями, полученными от iD3DXEffect через свой базовый класс ID3DXEffectBase. Эти функции, описанные в документации к DirectX SDK, принимают либо строковый литерал, либо дескриптор, идентифицирующий данную переменную. Обращение к пере- менным файла эффектов по дескрипторам является гораздо более эффективным, поскольку не включает в себя выполнение затратных операций сравнения строк, связан- ных с поиском переменной по имени. Класс cEffectFile пользуется этим преимущест- вом, заранее производя разбор файла эффектов во время его загрузки и организуя список дескрипторов переменных известных типов. В таблице 4.1 приведен перечень отдельных переменных, распознаваемых классом cEffectFile. Таблица 4.1. Отдельные пользовательские переменные, распознаваемые классом cEffectFile СЕМАНТИКА ТИП ДАННЫХ ОПРЕДЕЛЕНИЕ World Matrix Матрица преобразования объектного пространства в мировое View Matrix Матрица преобразования мирового пространства в простран- ство вида Projection Matrix Матрица преобразования пространства вида в пространство экрана WorldView Matrix World * View ViewProjection Matrix View * Projection WorldViewProjection Matrix World * View * Projection World MatrixArray Matrix Массив мировых матриц MaterialAmbient Color Цвет материала в рассеянном свете MaterialDiffuse Color Цвет материала в диффузном свете Material Emissive Color Цветсамосветящегося материала MaterialSpecular Color Цвет зеркального материала Material Power Float Сила зеркального материала
ОБЗОР ДВИЖКА Gaia 83 Таблица 4.1. Отдельные пользовательские перемен)гые. распознаваемые классом cEffectFile (Продолжение) СЕМАНТИКА ТИП ДАННЫХ ОПРЕДЕЛЕНИЕ CurNumBones Int Количество костей, влияющих на вершину в ходе скелетной ани- мации <имя>Х Texture Объект типа Texture с тем или иным именем, за которым следу- ет число от 0 до 16; напр., TexO, Texture? и т. д. Предварительный разбор семантики дескрипторов позволяет нам произвести внутрен- нюю установку нескольких битовых полей для указания того, какие переменные имеются в файле эффектов. Установим, например, бит для каждого элемента нумерованной тек- стуры, найденной в подобном файле. Сравнив эти разряды флага текстуры с аналогичными разрядами ресурса eSurf aceMaterial при помощи логической операции «И», мы можем быстро проанализировать материалы поверхностей и выяснить, содержат ли они нужное число текстур, требуемых для выполнения данного метода рендеринга. Родительским ресурсом объекта cEffectFile является cRenderMethod. В оконча- тельном варианте наш движок будет визуализировать сцену, делая множество разнообраз- ных шагов. Примерами этих шагов станут освещение и наложение текстуры рельефа. Чтобы произвести рендеринг сцены, позволим объектам содержать в себе несколько файлов эффектов, по одному на каждый шаг процесса рендеринга. Чтобы сохранить такие объекты cEffectFile, воспользуемся объектом cRenderMethod . cRenderMethod - это не что иное, как набор ссылок на файлы-объекты cEffectFile, одра ссылка приходится на один шаг рендеринга. Кроме того, данный класс способен хранить для каждого шага уникальный cSurfaceMaterial, что дает возможность представить его как процесс рен- деринга в целом. Пока же мы упростим ситуацию, используя единственный объект cEffectFile для всей модели. Это позволит нам быстро собрать и запустить движок, не углубляясь в тек- стуры рельефа и другие эффекты рендеринга. Мы вернемся к этой теме в третьей части книги, когда для достижения большего эффекта начнем использовать целый ряд шейдеров для одного объекта. А пока, даже несмотря на то что в наших моделях есть класс cRen- derMethod, набор будет содержать лишь один объект cEffectFile в области памяти, отведенной «по умолчанию». Буферы индексов и вершин Ресурсы буферов индексов и вершин часто используются в созданном примере движка, к тому же они полезны и как независимые ресурсы. Применительно к большей части данных в нашем движке следует сказать, что ресурсы модели в целом, которые будут описаны немного позднее, удовлетворяют нашим основным нуждам, оставляя буферы
84 Глава 4 индексов и вершин для тех случаев, когда традиционная модель оказывается непримени- мой. Те и другие буферы особенно полезны для динамических данных, требующих береж- ного обращения при работе, как в этом случае с резидентными ресурсами устройств. Применение динамических буферов индексов и вершин позволяет строить геометрию «на лету» или анимировать существующую геометрию на процессоре. При построении ландшафтного движка мы будем пользоваться и тем и другим методом. Внесем ясность: понятие динамический мы используем в значении полностью заме- няемый. Блокировка буфера вершин или индексов для изменения нескольких случайных значений на большинстве видеокарт ведет к ошеломляющему падению производитель- ности. Поэтому мы не будем поддерживать подобное действие в своем интерфейсе к этому типу ресурсов. В нашем движке мы будем полностью заменять содержимое дина- мических буферов при каждом их обновлении. Это позволит драйверу поддерживать одностороннюю передачу динамических данных. Как только они получены видеокартой, попыток считать их обратно в системную память для обновления нескольких конкретных значений не производится никогда. Базовые операции, необходимые для работы с нашими буферами, реализуют объекты классов cVertexBuffer и clndexBuffer. В них содержатся как обычные, так и динамические буферы. Для поддержки данных в динамических (читай: «заменяемых») буферах восполь- зуемся методом, рекомендованным NVIDIA и Microsoft как лучший способ 'обновления динамических данных. Согласно этому методу для хранения динамических данных служит буфер завышен- ного размера. Так, если ваши динамические данные содержат 10 вершин, создайте дина- мический буфер, достаточный для хранения ста. Одновременно в этом большом простран- стве вы будете по-прежнему использовать лишь 10 вершин. В первом кадре используйте вершины с 0-й по 9-ю. Индексы 10-19 используйте во втором кадре и т. д. При выходе за переделы буфера его содержимое полностью очищается, и процесс снова начинается с вершины с номером 0. Эта схема со скользящим окном считается самой дружественной, поскольку ее применение ведет к наименьшему числу прерванных операций прямого дос- тупа к памяти (DMA, Direct Memory Access). В листинге 4.5 показан псевдокод алгоритма, основанный на методе, кратко описанном в Microsoft DirectX 9 Developer FAQ [D9Faq]. ЛИСТИНГ 4.5. Рекомендуемый метод обновления динамических буферов индексов или вершин Создать объект (индексного или вершинного) буфера DirectX, используя флаги режимов D3DUSAGE_DYNAMIC и D3DUSAGE_WRITEONLY, а также флаг пула D3DPOOL-DEFAULT. Этот буфер должен как минимум вдвое превышать объем данных, которые вы намереваетесь использовать далее. // инициализировать значение индекса 1 = 0;
ОБЗОР ДВИЖКА Gaia 85 цикл для каждого кадра { // При данном размере буфера М //и объеме новых данных N... если (I+N < М) { I += N; Блокировать буфер, используя флаг D3DLOCK_NOOVERWRITE. Записать N единиц данных, начиная с индекса I Разблокировать буфер // Этим вы сообщаете Direct3D и драйверу о том, что // будете добавлять новые данные, но не будете изменять // какие бы то ни было данные, записанные вами раньше. // В результате, если в это время выполнялась операция // DMA, она продолжается без прерываний. } иначе { 1 = 0 Блокировать буфер, используя флаг D3DLOCK_DISCARD. Записать N единиц данных, начиная с индекса I Разблокировать буфер // Этим вы сообщаете Direct3D и драйверу о сбросе // содержимого буфера. Все прежние данные помечаются // как готовые к удалению. Если в это время выполнялась // операция прямого доступа к старым данным, то // драйвер вправе выделить вызывающей подпрограмме // совершенно новый буфер и отбросить старый, // когда он будет свободен } произвести рендеринг кадра, используя N единиц данных, начиная с индекса I } Ресурсы модели Класс cModelResource - это, пожалуй, важнейший объект ресурса, которым мы будем пользоваться, и, безусловно, тот класс ресурса, с которым мы будем иметь дело чаще всего. Он является контейнером целого дерева иерархии, построенного на основе D3DXFRAME. В силу того что в этих объектах может присутствовать много узлов, cModel- Resource может фактически представлять нечто большее, чем один физический объект
86- Глава 4 в процессе игры. Например, модель гладиатора может включать полный скелет из анимированных узлов, меши, имеющие наружный слой для представления одежды и тела, а также отдельные статические модели для каждого элемента оружия и доспехов. Каждый меш, в свою очередь, связан с объектами cRenderMethods и cSurfaceMaterial, что делает cModelResource представлением в нашем движке целой сущности, а не просто от- дельным фрагментом геометрии сцены. Как было сказано ранее, такой трюк с хранилищем мы выполняем, пользуясь иерархией D3DXFRAME. Эти объекты были описаны в главе 2 «Основные объекты» как идеальное сред- ство хранения скелетной анимации и отекстуренных мешей. Замечательное свойство реали- зации этих структур в D3DX состоит в возможности их расширения пользователем. Породив от D3DXFRAME и D3DXMESHCONTAINER собственные структуры, мы можем добавлять к данным, содержащимся в базовых классах, свои данные, зависимые от нашей платформы. Это обстоятельство важно для нас, потому что позволяет получить ссылки на собствен- ные нестандартные ресурсы cEffectFile и cSurfaceMaterial. Тем самым предоставленное D3DX простое дерево мешей, на которые отображается одна-единственная текстура, превра- щается в базу данных с множеством текстур и HLSL-шейдерами. Применение этих рас- ширенных классов требует создания ЭЗОХ-интерфейса для управления узлами в иерархии кадров. Однако первый шаг состоит в объявлении новых собственных типов данных. Лис- тинг 4.6 отражает наши расширения классов D3DXFRAME и d3dxmeshcontainer . Листинг 4.6. Классы в иерархии модели, порожденные от D3DXFRAME и D3DXMESHC0NTAINER //-------------------------------------------------- // Название: struct D3DXFRAME_DERIVED // Описание: Структура, порожденная от D3DXFRAME // и позволяющая дополнить ее данными // приложения, которые будут храниться // в составе каждого кадра //-------------------------------------------------- Struct D3DXFRAME_DERIVED: public D3DXFRAME { uintl6 frameindex; uintl6 parentindex; }; //-------------------------------------------------- // Название: struct D3DXMESHC0NTAINER_DERIVED // Описание: Структура, порожденная от D3DXMESHCONTAINER // и позволяющая дополнить ее данными приложения, // которые будут храниться в каждом из мешей
ОБЗОР ДВИЖКА Gaia struct D3DXMESHC0NTAINER_DERIVED: public D3DXMESHC0NTAINER { // данные о поверхности меша (SkinMesh) D3DXMESHDATA uint32 LPD3DXATTRIBUTERANGE DWORD uint8* LPD3DXBUFFER uint32 cSIMDMatrix* cRenderMethod* * RenderMeshData; NumAttributeGroups; pAttributeTable; NumBonSInfluences; pBonelndexList; pBoneCombinationBuf; NumBoneMatrices; pBoneOffsetMatrices; ppRenderMethods; Наши дополнения, внесенные в базовый класс D3DXFRAME, минимальны. Объекты типа d3DXFRame_derived, которые служат для создания дерева, размещаются в фиксированном массиве. Поэтому, несмотря на то что для работы с данными мы используем дерево, каждый его узел по-прежнему можем идентифицировать по уникальному индексу в линейном мас- сиве. Сделать это мы можем постольку, поскольку можем предположить, что деревья d3Dxframe_derived не изменяют динамически свой размер или порядок. После загрузки иерархия сохраняет неизменную конфигурацию до конца своей жизни. Для поддержки более подробной информации о семействах, в отличие от содержащей- ся в базовой структуре D3DXFRAME, мы можем воспользоваться значениями индексов в мас- сиве, где они хранятся с целью идентификации потенциальных родительских объектов или объекта-корня. Корневым узлом дерева в целом является объект frameindex, parentln- dex ссылается на непосредственного родителя узла. Значение -1 (Oxffff для слов) служит для указания на неиспользуемые значения индексов. Но почему бы не использовать указатели? Эти данные созданы как ссылочные объекты. В нашем мире может разместиться множество экземпляров этой модели, каждая со своим собственным множеством структур D3DXFRAME_DERIVED. Такие уникальные структуры содержали бы матрицы преобразования каждого отдельного экземпляра узлов кадра. Сохра- няя информацию о семействах как значения индексов, а не указатели, мы упрощаем созда- ние новых экземпляров этой модели до простого размещения в памяти нового массива объектов кадра и копирования данных. Дополнительные усилия не нужны, так как все индексы рассчитаны относительно корня массива, применяемого для хранения данных. Структура d3dxmeshcontainer_derived устроена немного сложнее. Поверх базового класса d3dxmeshcontainer мы добавляем все данные, характерные для движка. К ним относится список используемых мешем объектов cRenderMethod и cSurfaceMaterial, информация об ее оболочке и сам меш. Свою собственную структуру D3DMESHDATA мы храним отдельно от структуры, содержащейся в базовом классе, что позволяет поддержи-
88 Глава 4 вать независимо друг от друга одну версию модели в системной памяти, а другую - прошед- шую оптимизацию - в видеопамяти, где после загрузки ее использует наш метод рендеринга. В дополнение к возможности расширения базовых классов можно расширить и функции D3DX, которые служат для реализации файлового ввода-вывода дерева кадров и в которые мы можем включить свои данные. Тем самым формируется интерфейс, посредством которого мы можем так расширить исходный формат Х-файлов Direct? D, чтобы он удовлетворял нашим нуждам. Это потребует от нас создания трех ключевых интерфейсных классов: для управления выделением памяти и уничтожением наших структур данных, для управления сохранением этих структур данных в Х-файле и для загрузки данных из файла такого рода. s'- Все три интерфейса предоставлены классами D3DX: ID3DXAllocateHierarchy, 'ч-—ID3DXLoadUserData и ID3DXSaveUserData. Для добавления данных пользовате- ля мы просто породим свои классы от интерфейсов и реализуем те действия, которые предпо- лагаются в каждой чистой виртуальной функции, описанной в базовом классе. Работа этих функций показана в находящихся на компакт-диске файлах с исходным кодом d3dx_frame_manager. Эти файлы иллюстрируют подпрограммы выделения и очистки памяти, а также файлового ввода-вывода подробнее, чем мы могли бы даже надеяться сде- лать это в тексте самой книги. Узлы и объекты сцены Основой почти всех элементов нашего мира служат два виртуальных базовых класса движка: cSceneNode и cSceneObject. Узел cSceneNode формирует особую систему координат в трехмерной среде. Такие узлы могут быть связаны воедино в иерархии «родитель - потомок», породив всеобщий граф среды сцены. От базового класса cSceneNode в конечном счете происходят все входящие в нашу сцену объекты, включая cSceneObject. Хотя класс cSceneNode и управляет той информацией о матрицах преобразований и связях «родитель - потомок», которая определяет общий граф сцены, он не содержит никаких волюметрических данных. Вот где становится полезным cSceneOb j ect. Будучи основан на классе cSceneNode, cSceneObject вводит ограничивающий прямоугольник, который описывает объем пространства вокруг любого узла в локальном и мировом пространстве. Представляет ли cSceneObject модель игры или фрагмент ландшафта, этот прямоугольник дает грубую оценку пространства, занятого базовой геометрией. Имея в своем распоряжении базовый класс, содержащий матричное преобразование и выровненный параллельно осям ограничивающий прямоугольник объекта, мы распола- гаем согласованным интерфейсом, на основе которого можем построить любой харак- терный элемент в составе игры. Будь то участок травы, дерево или солнце, каждый объект поддерживает единое множество свойств, предоставленных классом cSceneObject. В главе 5 «Управление миром» мы еще раз применим этот базовый класс при построении
ОБЗОР ДВИЖКА Gaia 89 квадрадерева для управления пространственными отношениями между классами, которые порождаются от него. Класс cSceneObject является также основой нашего кон- ' вейера рендеринга, реализуя общий интерфейс, который может использоваться для обра- ботки объектов на каждом его шаге. Очередь на рендеринг Один из важнейших аспектов поддержки конвейера рендеринга, подобного нашему, - это контроль за количеством затратных операций смен состояния, которые запрашивает программа. Сменой состояния считается все, что вынуждает видеокарту модифицировать обработку наших моделей и наших текстур. Такие времяемкие смены состояния включают в себя активизацию вершинных и пиксельных шейдеров, а также изменение состояний рен- деринга D3D и сэмплеров текстуры. Данную проблему немного усложняет и применение файлов эффектов с участием D3DX, так как подобные файлы могут содержать мириады обновлений состояний рендеринга. Эти состояния могут быть в избытке внедрены во многие файлы эффектов, вызывая многократную установку одних и тех же значений. Управление этими избыточными сменами состояний мы доверим драйверу видеокарты, так как прямое управление ими нам недоступно. Однако мы можем принять на себя прямой контроль над активацией вариантов реализации файлов эффектов, чтобы гарантировать, что весь набор состояний рендеринга, включая вершинные и пиксельные шейдеры, не исполь- зуется в данной сцене больше одного раза. Более того, мы можем расширить наши возмож- ности в управлении, отслеживая активацию моделей, вершинных и индексных буферов, а также текстур. Задав приоритеты в смене состояний с учетом издержек, мы можем контро- лировать порядок, в котором те передаются на рендеринг видеоподсистеме. Это делается при помощи очереди на рендеринг, пребывающей в нашем основном коде под именем cRenderQueue. Такая очередь - это чуть больше, чем список объектов, рендеринг которых мы хотим провести. Вместо того чтобы показывать объекты непосред- ственно на экране, мы отправляем их в очередь для рендеринга. Когда все объекты поме- щены в очередь, мы можем отсортировать элементы очереди с учетом издержек, а затем произвести рендеринг всей сцены. Тонкость состоит в том, чтобы придумать способ представления элемента в очереди как компактного и легко поддающегося сортировке фрагмента данных. Основными составляющими процесса рендеринга являются методы работы с гео- метрией, материалами и визуализация как таковая. Эти три вещи вкупе с несколькими основными параметрами, такими, как матрицы преобразований, - вот все, что нужно, чтобы отобразить объект. К счастью, у нас уже есть краткое представление каждого из этих ингредиентов в виде объектов ресурсов cModelResource, cSurfaceMaterial И cEffectFile . Фактически наш менеджер ресурсов уже сопоставляет с каждым из этих
90 Глава 4 объектов 16-разрядный индекс, который отражает их положение в находящемся в памяти пуле объектов. Используя эти индексы, мы можем представить основные составляющие элемента очереди на рендеринг как значения из трех слов. Предположим, мы задаем порядок на значениях слов. Зная, что активация файла эффектов (состоящего из вершинных и пиксельных шейдеров) - это очень затратная операция, объявим индекс по cEffectFile старшим словом в трехсловном наборе. Далее по масштабу издержек следуют геометрические изменения, поскольку они состоят в работе с индексными буферами и потенциально многочисленными потоками вершин. Помня об этом, сделаем индекс по cModelResource вторым словом и закончим набор индексом по cSurfaceMaterial - младшее слово. Это построение трехсловных значений с учетом приоритета порождает 48-разрядный индекс с сортировкой, заданный в очереди на рендеринг. Если мы отсортируем ее эле- менты по 48-разрядным значениям, то гарантируем, что все подлежащие рендерингу объекты с одним и тем же значением cEffectFile будут сгруппированы внутри списка. В каждой из этих групп объединятся все объекты с одинаковыми геометрическими ресурсами cModelResource. Наконец, вместе окажутся все объекты в группе с одинако- вой геометрией, имеющие одни и те же материалы поверхности. Теперь упорядоченный список представляет более эффективное число смен состояний, необходимых для рен*$ деринга полной сцены. Код cRenderEntry развивает эту идею и представляет каждую посылку в очередь на рендеринг как 20-байтовое значение. Эта посылка гораздо больше, чем наш 48-битный пример, однако она включает в себя куда больше информации, по которой мы можем сортировать свои операции рендеринга. Так, cRenderEntry предусматривает 12 байт дан- ных, используемых для сортировки элементов, плюс 8 дополнительных байт, содержащих указатель на функцию обратного вызова и параметр, который задает пользователь. Это дает возможность вызывающей подпрограмме поставить элемент в очередь на рендеринг и указать функцию обратного вызова, которая должна быть запущена, когда возникнет^ необходимость в самом рендеринге. Объект cRenderQueue объединяет все элементы, сортирует по их приоритетам, а затем производит поочередный рендеринг, запуская функ- ции обратного вызова, которые ему были переданы. 12 байт данных, используемых при сортировке объектов cRenderEntry в пределах очереди, содержат ту же базовую информацию, что и в нашем примере: методы работы с геометрией, материалами и непосредственного рендеринга. cRenderEntry расширяете каждый из компонентов, вводя дополнительные параметры, необходимые для указания особенностей применения каждого из ресурсов. Так, метод рендеринга представлен в эле- менте очереди не только словным индексом ресурса cEffectFile, но и дополнительным параметром, который отражает то, какой из проходов файла эффектов используется
ОБЗОР ДВИЖКА Gaia 91 в данный момент. Вооруженные этими данными, мы можем отсортировать наш список по файлам эффектов и отдельным проходам по ним. Лучший способ знакомства с приоритетной очередью, которой мы будем пользо- ваться в нашем конвейере рендеринга, - это просмотр описаний классов cRenderQueue и cRenderEntry. Важный и значимый момент описания - флаги, которые заданы для каждой функции обратного вызова, выполняющей финальный рендеринг каждого эле- мента очереди. По мере прохождения по нашему списку очередь на рендеринг отслежи- вает те ресурсы, которые используются в данный момент. Когда в этом списке встречается новый ресурс, происходит обратный вызов, который содержит флаг, информирующий пользователя об активации нового ресурса. В листинге 4.7 приведены фрагменты классов cRenderEntry и cRenderQueue. Следующий за ним листинг 4.8 служит иллюстрацией того, как один из наших объектов cSceneModel пользуется очередью рен- деринга для отправки на рендеринг себя самого, а затем обрабатывает обратный вызов, в нужное время производя реальное растрирование модели. ЛИСТИНГ 4.7. Описания и основные фрагменты классов CRenderEntry и cRenderQueue /* cRenderEntry Элемент очереди на рендеринг - это 20-байтовый фрагмент данных, используемый для представления в очереди желаемых операций рендеринга. Верхние 12 байт представляют численное значение, позволяющее сортировать эти объекты, расставляя их в оптимальном для рендеринга порядке. Элементы очереди сортируются в ней по следующим данным... cPoolHandle hEffectFile; uint8 renderpass; uint8 renderParam :6; uint8 modelType :2; cPoolHandle hModel; uintl6 modelParamA; uintl6 modelParamB; cPoolHandle hSurfaceMaterial; modelType указывает на то, содержат ли значения hModel, modelParamA и modelParamB реальные данные модели или "сырые" индексы вершинных и индексных буферов. Значение modelType берется из
92 Глава 4 перечисления eTypeFlags в составе cRenderEntry. */ // эти флаги передаются производящим рендеринг // функциям обратного вызова и тем самым // сообщают объекту, какие из его компонентов // рендеринга нуждаются в активации enum eActivationFlagBits { k_activateRenderMethod = О, k_ac tivateRenderMethodPas s, k_activateRenderMethodParam, k_activateModel, k_activateModelParamA, k_activateModelParamB, k_activateSurfaceMaterial, k_totaiActivationFlags }; class cRenderEntry { public: // установим упаковку на уровне байтов, // чтобы гарантировать их плотное размещение #pragma pack(1) // ПОЛЯ, НЕОБХОДИМЫЕ ДЛЯ СОРТИРОВКИ ЭЛЕМЕНТОВ (12 байт) union { // это объединение дает возможность сортировать ’ // параметры рендеринга как три двойных слова struct { uint32 sortValueA; uint32 sortValueB; uint32 sortValueC; }; struct { // Нижеприведенные члены (перечисленные // в обратном - в плане приоритетов - порядке) // отображаются на sortValueA (первые 32 бита)
ОБЗОР ДВИЖКА Gaia 93 // пользовательские параметры // рендеринга, упакованные вместе // с типом модели (итого 1 байт) uint8 modelType : 2; uint8 renderParam : 6; // какой проход рендеринга использовать uint8 renderpass; // какой файл эффектов использовать cPoolHandle hEffectFile; // Нижеприведенные члены (перечисленные // в обратном - в плане приоритетов - порядке) // отображаются на sortValueB (вторые 32 бита) // вторичный вершинный буфер или кадр модели uintl6 modelParamA; // первичный вершинный буфер или индекс модели cPoolHandle hModel; // Нижеприведенные члены (перечисленные // в обратном - в плане приоритетов - порядке) // отображаются на sortValueC (третьи 32 бита) // используемый материал поверхности cPoolHandle hSurfaceMaterial; // индексный буфер или подмножество модели uintl6 modelParamB; }; }; // ДОПОЛНИТЕЛЬНЫЕ НЕСОРТИРУЕМЫЕ ПОЛЯ (8 байт) cSceneNode* object; uint32 userData; // теперь мы можем вернуться к упаковке по умолчанию #pragma pack() // эти значения перечисления используются для // установки значения modelType (см. выше). Тем // самым мы сообщаем очереди о том, представляют // ли данные в составе модели ресурсы модели или // набор вершинных и индексных буферов enum eTypeFlags { k_bufferEntry = 0, k_modelEntry, } ;
94 Глава 4 cRenderEntry(){}; -cRenderEntry(){}; // сбросим элемент в значения по умолчанию void clear() { sortValueA = 0; sortValueB = 0; sortValueC = 0; } }; // функтор сортировки cRenderEntry, // используемый в алгоритме Quicksort, // чтобы отсортировать очередь typedef cRenderEntry* LPRenderEntry; struct sort_less { bool operator()( const LPRenderEntry& a, const LPRenderEntry& b)const { if (a->sortValueA > b->sortValueA) { return false; } else if (a->sortValueA < b->sortValueA) { return true; } if (a->sortValueB > b->sortValueB) { return false; } else if (a->sortValueB < b->sortValueB) { return true; } if (a->sortValueC > b->sortValueC)
ОБЗОР ДВИЖКА Gaia 95 { return false; } else if (a->sortValueC < b->sortValueC) { return true; } return false; }; }; void cRenderQueue::sortEntryList() { // // Выполним стандартную быструю сортировку, // используя вышеописанный функтор sort_less // profile_scope(cRenderQueue_sortEntryList); // подробности реализации // см. в "core\quick_sort.h" Quicksort( m_entryList, m_activeEntries, sort_less()); } void cRenderQueue::reset() { m_activeEntries = 0; } / / 11 Эта функция отвечает за 11 выполнение очереди // void cRenderQueueexecute() { profile_scope(cRenderQueue_execute); if (m_activeEntries) { cDisplayManager& displayManager = TheGameHost.displayManager(); LPDIRECT3DDEVICE9 d3dDevice =
96 Глава 4 TheGameHost.d3dDevice(); // отсортируем список элементов sortEntryList(); // инициируем обратный вызов для рендеринга // первого элемента очереди и передадим все // флаги активации, предварительно установив их u32Flags activationFlags(Oxffffffff); m_entryList[0]->object->renderCallback( m_entryList[0], activationFlags); / / произведем рендеринг всех дополнительных // элементов, посылая флаги лишь для ресурсов, II которые должны подвергнуться активации for (int i=l; i<m_activeEntries; + + i) { cRenderEntry* currentEntry = m_entryList[i]; cRenderEntry* previousEntry = m_entryList[i-1]; activationFlags.value=0; // // проверим наличие изменений в файле эффектов // if (previousEntry->hEffectFile ! = currentEntry->hEffectFile) { . // завершим работу последнего метода рендеринга cEffectFile* pLastMethod = displayManager.effectFilePool(). getResource(previousEntry->hEffectFile); if (pLastMethod) { pLastMethod->end(); safe_release(pLastMethod); } SET_BIT(activationFlags, k_activateRenderMethod); SET_BIT(activationFlags, k_activateRenderMethodPass); SET_BIT(activationFlags, k_activateRenderMethodParam) ; } else if (previousEntry->renderPass != currentEntry->renderPass)
ОБЗОР ДВИЖКА Gaia 97 { SET_BIT(activationFlags, k_activateRenderMethodPass); SET_BIT(activationFlags, k_activateRenderMethodParam); } else { if (previousEntry->renderParam 1= currentEntry->renderParam) { SET_BIT(activationFlags, k_activateRenderMethodParam); } } // // проверим наличие изменений в модели // if (previousEntry->hModel != currentEntry->hModel II previousEntry->modelType != currentEntry->modelType) { SET_BIT(activationFlags, k_activateModel); SET_BIT(activationFlags, k_activateModelParamA); SET_BIT(activationFlags, k_activateModelParamB); } else { if (previousEntry->modelParamA != currentEntry->modelParamA) { SET_BIT(activationFlags, k_activateModelParamA); } if (previousEntry->modelParamB != currentEntry->modelParamB) { SET_BIT(activationFlags, k_activateModelParamB); } } // // Проверим наличие изменений в материалах поверхности 4- 1Я14
98 Глава 4 // if (previousEntry->hSurfaceMaterial != currentEntry->hSurfaceMaterial) { SET_BIT(activationFlags, k_activateSurfaceMaterial); } // // инициируем обратный вызов для рендеринга // currentEntry->object->renderCallback( currentEntry, activationFlags); } II завершим работу последнего метода рендеринга cRenderEntry* lastEntry = m_entryList[m_activeEntries-l]; cEffectFile* pLastMethod - DisplayManager.effectFilePool(). getResource((cPoolHandle)lastEntry->hEffectFile); if (pLastMethod) { pLastMethod->end(); safe_release(pLastMethod); } } // произведем сброс для перехода к следующему кадру reset(); } ЛИСТИНГ 4.8. Пример пары функций как иллюстрация применения cRenderEntry и cRenderQueue // упрощенная версия функции рендеринга // cSceneModel используется для добавления // вложенной модели в очередь на рендеринг void cSceneModel::render() { const D3DXMESHCONTAINER_DERIVED* pMeshContainer = meshContainer() ; if (pMeshContainer != NULL && pMeshContainer->ppRenderMethodList) {
ОБЗОР ДВИЖКА Gaia 99 for (UINT iMaterial = 0; iMaterial < pMeshContainer->NumMaterials; iMaterial++) { cRenderMethod* pMethod = pMeshContainer->ppRenderMethodList[iMaterial]; if (pMethod) { cEffectFile* pEffect = pMethod->getEffeet( TheGameHost.currentRenderStage()); cSurfaceMaterial* pMaterial = pMethod->getMaterial( TheGameHost.currentRenderStage()); if (pEffect && pMaterial) { uintl6 numPasses = pEffect->totalPasses(); for(uintl6 iPass = 0; iPass < numPasses; iPass++ ) { cRenderEntry* pRenderEntry = DisplayManager.openRenderQueue(); pRenderEntry->hEffectFile = (uint8)pEffect->resourceHandle(); pRenderEntry->hSurfaceMaterial = pMaterial->resourceHandle(); pRenderEntry->detailLevel = m_lod; pRenderEntry->modelType = cRenderEntry::k_modelEntry; pRenderEntry->hModel = m_pModelResource->resourceHandle(); pRenderEntry->modelParamA = m_modeIFrameindex; pRenderEntry->modelParamB = iMaterial; pRenderEntry->renderPass = (uint8)iPass; pRenderEntry->object = (cSceneNode*)this; pRenderEntry->userData - iMaterial;
100 Глава 4 DisplayManager.closeRenderQueue( pRenderEntry); } } } } } } // эта функция вызывается очередью на рендеринг // для фактического рендеринга модели. void cSceneModel::rendercallback( cRenderEntry* entry, u32Flags activationFlags) { LPDIRECT3DDEVICE9 d3dDevice = TheGameHost.d3dDevice(); const D3DXMESHCONTAINER_DERIVED* pMeshContainer = meshContainer(); bool skinModel= pMeshContainer->pSkin!nfo != NULL; UINT iMaterial = entry->userData; cRenderMethod* pMethod = pMeshContainer->ppRenderMethodList[iMaterial]; cEffectFile* pEffect = pMethod->getEffeet( TheGameHost.currentRenderStage()); eSurfaceMaterial* pMaterial = pMethod->getMaterial( TheGameHost.currentRenderStage()); if (pEffect && pMaterial) { // нужно ли активировать метод рендеринга? if (TEST_BIT( activationFlags, k_activateRenderMethod)) { pEffect->begin(); } // нужно ли активировать проход при рендеринге? if (TEST_BIT( activationFlags, k_activateRenderMethodPass) || TEST_BIT(
ОБЗОР ДВИЖКА Gaia 101 activationFlags, k_activateRenderMethodParam) || test_bit( activationFlags, k_activateRenderMethodLOD)) { m_pModelResource->setLOD(m_lod); if (skinModel) ( int numBonelnfluences = pMeshContainer->NumBone!nfluences-1; pEffect->setParameter( cEffectFile::k_bonelnfluenceCount, &numBoneInfluences); } pEffect->activatePass(entry->renderPass); } // нужно ли активировать материал поверхности? if (TEST_BIT( activationFlags, k_activateSurfaceMaterial) ) { pEffect->applySurfaceMaterial(pMaterial); } const cCamera* pCamera = TheGameHost.activeCamera() ; D3DXMATRIX matWorldViewProj = (D3DXMATRIX)worldMatrix() * (D3DXMATRIX)pCamera->viewProjMatrix(); // установим матрицу вида pEffect->setMatrix( cEffectFile::k_worldViewProjMatrix, &matWorldViewProj); pEffect->setMatrix( cEffectFile::k_worldMatrix, &worldMatrix() ) ; // нарисуем подмножество меша m_pModelResource->renderModelSubset( entry->modelParamA, entry->modelParamB );
102 Глава 4 Редактор модели На компакт-диске, прилагаемом к этой книге, есть файл ' ----' shader_edit_debug_exe. Этот простой тест функций работы с системой ресурсов и файловым вводом-выводом позволяет загружать модели из Х-файлов Direct3D, добавлять текстуры и описывать методы рендеринга, после чего просматривать вывод в окне результатов. Также он поддерживает воспроизведение анимации, способен загружать и дописывать новую анимацию в файл. После внесения изменений модели можно сохра- нять как новые Gaia-расширенные Х-файлы с сохранением ссылок на файлы со всей ин- формацией о текстурах и шейдерах. Кроме того, это приложение служит иллюстрацией того, как наш движок применяется к библиотеке Microsoft Foundation Classes (MFC), по- лезной при создании инструментов такого рода. Литература [FAQ] Microsoft DirectX9.0 Developer FAQ (ресурс доступен по адресу http://msdn.mi- crosoft. com/library/en-us/dndxgen/html/directx9devfaq.asp').
Часть II Введение в системы ландшафтного синтеза Теперь, когда вводная часть книги осталась уже позади, мы, пользуясь базовым ядром отображения и основами D3DX, можем продвинуться дальше в изучении методов рен- деринга ландшафта. В этой части мы опишем обе фундаментальные составляющие любого добротного ландшафтного ядра: наземную геометрию и текстуры. Эти два эле- мента станут основой нашего мира и той поверхности, которую мы заполним водой, тра- вой, деревьями и цветами. Говоря о двух важнейших аспектах создания ядра, мы можем обсудить много различных методик. Самые популярные из них мы подробно представим и расскажем, как реализована каждая. Впрочем, демо-версия нашего ядра будет сосредоточена вокруг ряда ключевых методов, которыми мы будем пользоваться в последующей демонстрационной программе. Эту часть книги мы начнем с изложения дополнительной информации об основах ядра. Так, мы обсудим вопросы управления нашим миром и подход к разбиению пространства на более управляемые подобласти при помощи квадрадеревьев. Этот тип пространственных структур управления мы обязаны рассмотреть до того, как приступим к созданию методов организации хранения геометрии ландшафта. Кроме того, ценность квадрадеревьев состоит в сокращении числа объектов, рендеринг которых мы будем пытаться произвести, а это по- зволит нам быстро находить только те объекты, которые, скорее всего, будут видны на экране. В трех следующих главах речь пойдет о земной поверхности как таковой. Начало этому мы положим в главе 6 «Основы ландшафтной геометрии», в которой обсудим механизм ввода информации о ландшафте - снискавшую заслуженное уважение карту высот. Здесь же мы опишем методы создания этих данных в пакетах рисования или при помощи процедур, содержащих элементы случайности. Когда определение источника данных будет завершено, мы сможем преобразовать карту высот в реальную информацию о вершинах и произвести рендеринг «грубой силой», чтобы увидеть свои данные на экране.
104 Две следующие главы - глава 7 «Ландшафтная система ROAM» и глава 8 «Методы мо- заичной геометрии» - служат введением в проблемы управления геометриями ландшафта. Теперь, когда наши данные представлены в форме вершин, мы видим, что столь огромное их число вряд ли можно визуализировать с достаточно большой скоростью. Чтобы снять остроту этой проблемы, мы обратимся к ряду популярных схем управления, предпола- гающих наличие режимов с разными уровнями детализации (LOD, level-of-detail). Отобра- жая более подробно геометрию вблизи камеры и менее детально - на расстоянии, мы можем уменьшить общее количество данных, которые обрабатываем при рендеринге, и создать более эффективное ядро. В главе 9 «Методы текстуризации» мы введем в ландшафтную геометрию карты текстур, создав реалистичный пейзаж. В качестве первой пробы пера при написании HLSL-шейдеров эта глава станет введением в начальную настройку и применение мето- дов смешивания текстур и простых методов освещения. Будучи еще слишком несо- вершенным для настоящих шедевров, наш первый подход к рендерингу покажет, что ландшафт покрыт текстурой и представляет такие реальные поверхности, как трава, скалы, песок. Результатом же станет «пустой» текстурированный пейзаж с эффективным LOD-управлением, который будет прекрасной отправной точкой, откуда мы сможем начать третью, заключительную часть нашей книги.
Глава 5 УПРАВЛЕНИЕ МИРОМ Прежде чем мы сможем начать создание нашего 3 D-ландшафта, рассмотрим послед- нюю из глав, связанных с вопросами об основах. Помните о нашем желании полностью построить ядро ландшафтного генератора по мере изучения этих тем. Для этого мы и должны обсудить ряд фундаментальных задач, прежде чем наш собственный мир при- обретет реальные очертания. Для начала нам надо поговорить о масштабе стоящей перед нами задачи. Планеты весьма велики, и самое интересное- осматривать холмы или горные их районы. Как можно было и ожидать, работать с такими зонами труднее всего, к тому же они требуют огромного количества данных для представления в ЗВ-пространстве1. Одна из ключевых задач в работе со столь большими объемами данных и состоит в том, чтобы гарантировать то, что ядро может сосредоточиться на тех зонах, которым необходим максимум внима- ния, и игнорировать остальные. Обычно этого достигают при помощи разбиения про- странства (space partitioning) того или иного типа. Разбить пространство - значит поделить весь мир на участки, позволив ядру опреде- лять, каким из участков нужен рендеринг, а каким - нет. Кроме того, ядру дается возмож- ность решать, какой степени детализации требует при рендеринге каждый участок, что ускоряет ход обработки менее важных из них. Самые эффективные методы запоминают эти участки во вложенной древовидной структуре, что позволяет отбросить многие из них или классифицировать их посредством одной проверки. Работая с нашим ядром, мы обсу- дим метод, основанный на квадрадеревьях, и пути его улучшения для достижения еще большей его эффективности. 1 Я бы сказал, что горы и холмы - это как раз основа любого генератора ландшафта, которые более-менее легко поддаются синтезу. А вот реалистичная вода, лес и прочие динамические объекты требуют гораздо больше аппаратных ресурсов и соответственно усилий разработчика, чтобы их правдоподобно реализовать. - Примеч науч. ред.
106 Глава 5 Помня о целях книги, мы сосредоточим свое внимание на небольших участках ланд- шафта и покажем на них работу наших методик. Затем мы сделаем из крошечного участка земли остров и окружим его водным пространством. Квадрадерево позволит определить, какие участки острова будут видны, однако возможности этого метода куда шире наших ограниченных условий для демонстрации. При помощи квадрадерева (хотя и большего по размеру) ландшафтное ядро могло бы управлять множеством «островков» суши, поль- зуясь деревом для выяснения того, какие из них находятся вблизи камеры. Это позволило бы программе динамически загружать данные с жесткого диска, по мере того как камера исследует все новые участки поверхности. Результат этого - кажущийся бесконечным пейзаж, при построении которого ценные ресурсы памяти будут тратиться лишь на нужную информацию. Участки, расположенные вне поля зрения камеры, могут уда- ляться из памяти, ожидая того момента, когда они понадобятся опять. Что стоит за организацией сцены Первый шаг в работе любого конвейера рендеринга состоит в том, чтобы определить, какие объекты нуждаются в отображении. В этом - основная причина нашего решения о разбиении пространства. Анализ видимости - это простое выяснение того, какие объек- ты находятся в поле зрения камеры. Поле зрения представляет собой шестигранник, который описывает объем пространства, видимый через камеру. Простое представление ряда объектов и пример поля зрения показаны на рис. 5.1. Рис. 5.1. Граничные плоскости поля зрения 20-камеры. Объекты В и С имеют точки в положительном полупространстве всех плоскостей, а значит, лежат в поле зрения камеры
УПРАВЛЕНИЕ МИРОМ 107 Чтобы определить, виден объект или нет, мы должны выяснить, лежит ли в поле зрения какая-то его часть. Это можно проделать множеством способов. Если предполо- жить, что объект может быть окружен той или иной ограничивающей фигурой, такой, как сфера или прямоугольник, проверка видимости становится серией из шести тестов, по одному на каждую сторону поля зрения. Каждая сторона поля зрения считается плоскостью в 3 D-пространстве. Каждая плос- кость имеет нормальный вектор, который указывает внутрь поля зрения. Нормаль опреде- ляет положительное полупространство конкретной плоскости. При заданных шести плос- костях для каждого объекта осуществляется шесть проверок, которые позволяют понять, лежит какая-то часть объекта в положительном полупространстве каждой из плоскостей или нет. Если часть объекта находится в положительном полупространстве всех шести плоскостей, то он лежит в поле зрения и является видимым. Иллюстрация этого в 2D-npo- странстве, где используется поле зрения с четырьмя сторонами, показана на рис. 5.1. Как вы могли и подумать, в плане производительности эти проверки обходятся доста- точно дорого. Даже если представить каждый объект ограничивающим параллелепипе- дом, нам все равно придется проверить восемь вершин (углов параллелепипеда) каждого из объектов. Умножьте количество вершин на число сторон поля зрения, и вы увидите, что каждый объект требует 48 проверок с участием полупространства той или иной плос- кости. Это слишком много, чтобы считать такое решение эффективным для полностью «заселенных» пейзажей, которые могут содержать тысячи объектов, требующих проверки видимости в каждом из кадров. Задача существенно сократится, если использовать метод организации сцены. Допус- тим, что мы способны разбить пространство на более мелкие области, каждая из которых представлена прямоугольным объемом, именуемым сектором. По мере перемещения по нашему миру объекты переходят из сектора в сектор, и мы следим за тем, какие из них относятся к каждому сектору. Когда же приходит время определять, какие объекты нужно подвергнуть рендерингу, мы просто выясняем, какие из прямоугольных секторов нам видны. Каждый объект, находящийся в таком секторе, соответственно считается видимым и отправляется в конвейер рендеринга. Выиграв в эффективности, мы потеряем в точности. Не все объекты в пределах сек- тора могут быть реально видны, поскольку часть самого сектора может лежать вне поля зрения камеры. Однако если мы знаем, что прямоугольник данного сектора задел угол поля зрения, то можем сделать еще один шаг и отдельно проверить каждый объект внутри самого сектора. Тем самым мы восстановим точность при неизменном сокращении общего числа выполненных проверок, поскольку узлы, полностью лежащие вне или внутри поля зрения, не нуждаются в дополнительных тестах. Итог дискуссии об эффек- тивности, достигнутой за счет применения секторов, подводит рис. 5.2.
108 Глава 5 Рис. 5.2. Эффективность, достигаемая делением по секторам. В отсутствие секторов подход, основанный на «грубой силе», потребует отдельной проверки всех 24 кр утлых объектов. В этом примере подход, основанный на секторах, потребует проверки пяти занятых объектами секторов, показанных белым цветом, и сокращает количество тестируемых объектов до 10 Эти простые>действия над секторами имеют свой недостаток. Даже в том случае, если мы воспользуемся подходом, основанным на секторах, проблема, связанная с необходимостью проверки видимости границ всех занятых секторов, по-прежнему остается. Если объекты в нашей игре разбросаны достаточно равномерно, то приняв этот подход на деле, мы можем не получить никакого выигрыша в эффективности. Однако мы показали, что группировка объектов по секторам несет в себе потенциал роста эффективности наших действий. Поэтому, сгруппировав секторы в более крупные образования, мы можем достичь большей эффектив- ности за счет ограничения числа занятых секторов, которые необходимо проверить. Вложение секторов порождает дерево иерархии. Каждый сектор содержит набор меньших, дочерних секторов, совместно заполняющих пространство, которое задает их родитель. Если родительский сектор не виден, то не видны и потомки. Если же родитель виден целиком, полностью видны и дочерние секторы. Если родитель частично входит в объем, участвующий в проверке видимости, мы перейдем к каждому из его потомков, проверив их как новые родительские секторы. Такая рекурсивная проверка продолжается до тех пор, пока не будут найдены все видимые секторы или пока мы не покинем подле- жащие проверке уровни дерева.
УПРАВЛЕНИЕ МИРОМ 109 Базовый вариант квадрадерева Квадра- и октадеревья лучше всего представляют понятие вложения секторов в нашем дереве иерархии. Каждое из них - это жестко заданная иерархия типа «дерево», каждый узел которой содержит равное число дочерних узлов. Квадрадеревья, по сути, имеют двухмерную пространственную организацию. Каждый 2В-прямоугольник внутри квадра- дерева делится поровну на четыре дочерних узла, упорядоченных в сетку размером 2x2. Каждый дочерний узел может быть поделен на четыре более мелких потомка и т. д. Окта- деревья суть квадрадеревья, расширенные до трех измерений, каждый сектор в которых имеет восемь дочерних узлов, заданных в виде сетки 2x2x2. Полного 3 D-представления данных в нашем ландшафтном ядре и не нужно. С учетом пропорций наша среда окажется весьма плоской. Пейзаж может быть очень протяженным по осям х и у, но занимать по вертикали так мало места, что по сравнению с горизонталью об этом можно не беспокоиться. Поэтому мы можем использовать квадрадеревья и эле- ментарную схему 2О-размещения в четыре потомка, отказавшись от более точного метода на основе октадеревьев. Как мы увидим позднее, мы сможем и дальше ввести некие све- дения о высоте, или оси z, для расширения своего квадрадерева до псевдо-ЗО-иерархии; пока же сосредоточим свое внимание на двухмерном подходе. Традиционно квадрадеревья создают так, чтобы включать в них минимальное количе- ство узлов. Если ветвь дерева может быть признана пустой (ниже нее в дереве нет ни одного объекта), то все узлы в этой ветви можно отбросить. Эта стратегия предпола- гает минимум места для хранения дерева, так как пустые узлы не расходуют память, однако она может стать причиной множества операций динамического выделения и очи- стки памяти при наличии подвижных объектов. Чтобы упростить процесс разработки и сохранить большую управляемость квадра- деревьев, воспользуемся так называемым вполне развернутым представлением дерева. Другими словами, все узлы дерева существуют, даже если они пусты. Таким образом, при движении объектов по нашему миру нам не придется думать о динамическом создании или уничтожении узлов, при этом расход памяти, отводимой для хранения дерева, оста- нется хоть и большим, но постоянным. Итак, задача принимает вид: как распределить объекты по узлам внутри дерева? Посмотрим на рис. 5.3. Воспользуемся этими картинками и покажем на них процесс раз- мещения объекта на дереве. На первом рисунке мы начинаем работу с самым верхним узлом. Этот узел содержит в себе весь мир, а также представленный тестовый объект. Рас- положение объекта в нужном дочернем узле является рекурсивной задачей. Если сейчас объект принадлежит корневому узлу, то следующий рекурсивный процесс мы будем осуществлять до тех пор, пока не найдем окончательный узел.
110__________ Глава 5 1. Выясним расположение объекта относительно всех потомков текущего узла дерева. Если дочерних узлов нет, сразу перейдем к шагу 3. 2. Если объект полностью содержится в одном дочернем узле, сделаем этот дочерний узел текущим и повторим шаг 1. 3. Объект является членом данного узла. Внесем его в список членов текущего узла и закончим работу. На рис. 5.3 эти шаги выполняются трижды. Каждый раз мы находим узел-потомок, содержащий объект, и процесс повторяется. На последнем рисунке мы видим, что объект входит более чем в один узел-потомок (пунктир) и не может стать членом ни одного из них. Взамен он становится членом родительского узла - минимального узла, который его содержит. На рис. 5.4 приведено дерево иерархии, созданное по ходу процесса, объект же показан ниже того узла, где он сам расположен. о о 1 2 3 Рис. 5.3. Серия рекурсивных операций над квадрадеревом, необходимых для размещения объекта в конкретном его узле Уровень 1 Уровень 2 Уровень 3 Рис. 5.4. Иерархия квадрадерева, созданная в ходе процесса, показанного на рис. 5.3
УПРАВЛЕНИЕ МИРОМ 111 Расширение квадрадеревьев Теперь, когда мы понимаем цель нашего обращения к квадрадеревьям и их структуру, а также знаем, как включать в состав квадрадеревьев объекты, мы можем рассматривать отдельные детали реализации, которые делают деревья более полезными и эффективными. Как было сказано выше, мы будем пользоваться вполне развернутыми деревьями, что объясняется их простотой и возможностью работы с фиксированным объемом памяти. Кроме того, они предполагают нетривиальную схему менеджмента, которой предусмотрена прямая сортировка объектов дерева без выполнения ранее показанных рекурсивных шагов. Эту идею мы заимствуем у Мэтта Притчарда (Matt Pritchard), который описал 4 -—' метод прямого доступа к квадрадеревьям в статье, опубликованной в Game Programming Gems 2 [Pritchard], Здесь мы дадим краткий обзор этого метода. Более подробное его изложение можно найти в статье Притчарда; кроме того, вы можете озна- комиться с исходным кодом нашей реализации метода на прилагаемом компакт-диске. Вкратце, метод использует природу логической операции «исключающее ИЛИ» (XOR), а также тот факт, что каждый узел квадрадерева делится по центральной точке каждой оси. Если входящие в состав квадрадерева секторы сохранить как целые значения степени числа 2, то вычисление XOR для границ любого объекта ведет к образованию битового шаблона, который может быть протестирован для отыскания нужного уровня дерева с целью размещения в нем объекта. Как и большая часть алгоритмов ком- пьютерной графики, звучит это сложно, однако пользоваться этим на деле очень легко. На рис. 5.5 мы видим квадрадерево 256 х 256, - его размер вдоль каждого измерения является степенью двух и требует 8 бит данных на каждую ось (один байт без знака). Мы знаем, что узлы квадрадерева поделены равномерно, поэтому границы первого уровня проходят по метке 128 на каждой оси, определяя четыре потомка в узле, что и показывают пунктирные линии. В двоичном представлении мы можем заметить, что каждый уровень дерева может быть опознан по самому старшему биту, который использует секущая плос- кость. Примеры см. в таблице 5.1. Чтобы определить, на какой уровень дерева следует поместить наши объекты, про- верим их положение вдоль каждой оси. Взятие логической операции XOR в пределах оси дает нам в результате шаблон битов. Если старший его разряд установлен, то это говорит о том, какую секущую плоскость пересекает объект, а значит, и членом какого уровня в составе дерева он должен стать. Так, на рис. 5.5 показаны два объекта. Глядя на объект А, мы видим, что по оси х он занимает позиции со 126-й по 130-ю. Расчет XOR этих значе- ний позволит выявить следующее: 126 (двоичное 0111111.0) XOR 130 (двоичное 10000010) 252 (двоичное 11111100)
112 Глава 5 Рис. 5.5. Квадрадерево, размер которого по каждому измерению есть целая степень двух В итоговом шаблоне битов мы видим, что старший единичный разряд является 7-м битом. Вычтя это значение из максимального номера бита, также \ мы получаем 0. Это свидетельствует о том, что объект А лежит на 0-м уровне дерева (на уровне корня). Обра- тившись к таблице 5.1, мы обнаружим, что 7-й бит результата операции XOR на деле соответствует битовому шаблону разбиения на интервалы длины 128, которое осуществ- ляется на 0-м уровне нашего дерева. Второй объект, В, на оси х расположен между точками 190 и 195. Взятие XOR этих значений дает в итоге 125 (двоичное 1111101). Старший единичный разряд результата имеет 6-ю позицию. Согласно таблице 5.1 6-я позиция указывает на 1-й уровень дерева. То же мы могли получить, вычтя номер старшего единичного бита (6) из максимального номера бита (7). Мысленный проход по дереву опять же подтверждает тот факт, что объект В следует поместить на один уровень ниже корня, поскольку В пересекает очеред- ную линию рассечения в точке 192. О чем мы не сказали, так это, конечно, о том, что уровни дерева должны быть заданы и вдоль оси у. В конечном итоге уровень, где расположен объект, должен быть выбран как меньшее из двух значений. Так, если область, занятая объектом по оси х, будет соответст- вовать 5-му уровню узлов дерева, а область, занятая объектом по оси у, - 3-му уровню, мы
УПРАВЛЕНИЕ МИРОМ 113 поместим объект на 3-й уровень дерева. Расположение объекта в той области дерева, чья глубина больше, нарушит правила построения квадрадеревьев, так как объект пересекает секущую плоскость на 3-м уровне и не должен быть помещен на большую глубину. Таблица 5.1. Битовые шаблоны, предназначенные для идентификации уровней квадрадеревьев, размеры которых являются степенью числа 2 УРОВЕНЬ ИНТЕРВАЛЫ В СЕКУЩЕЙ ПЛОСКОСТИ ДВОИЧНОЕ ПРЕДСТАВЛЕНИЕ ИНТЕРВАЛА СТАРШИЙ БИТ 0 128 10000000 7 1 64 01000000 6 2 32 00100000 5 3 16 00010000 4 4 8 00001000 3 5 4 00000100 2 6 2 00000010 1 7 1 00000001 0 Для отыскания старшего единичного бита результата операции XOR воспользуемся ассемблерной инструкцией BSR, или Bit Scan Reverse (Обратный просмотр разрядов). Эта простая инструкция х86 выдает индекс старшего установленного разряда числа гораздо быстрее, чем мы могли бы проделать это своими силами на языке C/C++. В средствах матема- тической поддержки, поставляемых с примером ядра (и описанных в приложении А «Служеб- ные классы Gaia»), мы предлагаем функцию highestBitSet, которая решает эту задачу. Как только искомый уровень дерева будет известен, последним шагом станет опреде- ление того, какой именно узел данного уровня является родителем проверяемого объекта. Равенство размера нашего дерева степени числа 2 снова делает эту задачу совсем не- сложной. Если все секторы на данном уровне узлов дерева хранятся как двухмерный массив, то нужный сектор мы можем найти, взяв координаты объекта и приведя их к масштабу сетки узлов данного уровня дерева. К примеру, мы рассчитали, что объект В на рис. 5.5 будет расположен на 1-м уровне дерева. Первый уровень дерева содержит сетку узлов размером 2x2. Поэтому координаты объекта В должны быть сведены к диапазону [0, 1 ] по каждой оси, что и позволит найти индексы столбца и строки для размещения в них объекта. Этот трюк осуществляет простая операция сдвига. Координаты нашего мира лежат в диапазоне [0, 255] и являются значениями длины 8 бит. Чтобы свести их к диапазону [О, 1], нам нужно преобразовать их в значения из 1-го разряда. Это преобразование реали- зует сдвиг значений координат вправо на 7 позиций (8-разрядное значение к 1 -разрядному сводится сдвигом вправо на 7). Возьмем координаты объекта В (190, 20), сдвинем их
114 Глава 5 вправо на семь позиций и получим значения индексов (1,0). Эти индексные значения как раз и указывают, какой из узлов в сетке 1-го уровня является родителем объекта В. Вы должны обратить внимание, что работа этого метода целиком зависит от квадра- дерева размером 256 х 256 единиц. Наш мир не построен на целых числах и вряд ли соот- ветствует такому масштабу. Чтобы использовать такой быстрый метод прямого доступа к квадрадеревьям, мы должны взять координаты нашего реального мира и преобразовать их в табличное пространство. Это подразумевает перевод значений естественного диапа- зона в диапазон [0, 255] и их конверсию из вещественных в целочисленные значения. Введение дополнительного измерения квадрадеревьев Как было сказано в начале нашего разговора, квадрадеревья представляют собой метод пространственной сортировки на плоскости. Расширив их до трех измерений, получим октадеревья, каждый узел которых имеет восемь потомков, упорядоченных в виде сетки 2x2x2. Неявно это означает и развитие наших математических методов и процедур быстрого просмотра с учетом расположения каждого объекта вдоль оси г. При этом, хотя такое расширение является совершенно разумным, оно повышает сложность и увеличивает издержки, связанные с хранением наших элементарных квадрадеревьев с прямым доступом. Вместо работы с полнофункциональными октадеревьями, мы можем сделать лишь полшага в 3D, добавив к каждому из объектов 32-битное поле. В этом поле мы зададим один бит на каждую область пространства объектов по оси z. Чтобы понять эту идею, представьте себе, что вдоль оси z мы делим наш мир на 32 равных слоя. Для каждого из объектов в нем мы можем построить число из 32 разрядов, установив разряды для каж- дого слоя, где находится наш объект. Пример с восьмью слоями см. на рис. 5.6. По мере того как в квадрадерево включаются новые объекты, мы (пользуясь опера- цией ок) логически складываем их битовые поля для оси z. Так формируется битовое поле оси z, которое характеризует содержимое данного узла. Осуществляя поиск объектов по квадрадереву, мы можем задать 20-геометрию поиска, а также битовое поле слоев высоты по оси z, в которых хотим отыскать свои собственные объекты. Простая операция and над соответствующим битовым полем каждого узла сразу же позволит узнать, нахо- дятся ли члены узла в исследуемых нами слоях. Если логическое and дает ненулевой результат, мы знаем, что в этих слоях есть какие-то члены, и, чтобы найти их, нам надле- жит отдельно протестировать каждый. Пользуясь этим методом, мы имеем все плюсы быстрого и компактного представле- ния квадрадеревьев, получая при этом механизм анализа третьего измерения. И хотя раз- биение оси z только на 32 слоя является достаточно спонтанным решением, основанный на выполнении логических операций над 32-битными значениями механизм тестов рабо-
УПРАВЛЕНИЕ МИРОМ 115 тает крайне быстро. В итоге, разбивая пространство, мы без особых издержек получаем новый уровень точности. Итоговый битовый шаблон для оси z Рис. 5.6. В мире, поделенном на восемь слоев, мы можем построить 8-разрядное числовое значение, которое опишет слои, занимаемые объектом. На этом рисунке объект занимает 2, 3 и 4-й слои, что соответствует битовому шаблону 00011100 или, в десятичной записи, числу 28 Быстрый поиск в квадрадеревьях Поиск по квадрадеревьям начинается с тех же операций, что и расстановка объектов. При данном трехмерном пространстве поиска мы формируем 21Э-пространство и 32-разряд- ное значение, которое представляет расположение исходной ЗЭ-формы на оси z. Чтобы начать поиск, мы должны определить тот узел дерева, который содержит 2О-пространство поиска целиком. Эта процедура в точности совпадает с той, которую мы осуществляли при размещении искомого пространства внутри нашего дерева. Найденный узел как прямой предок пространства поиска является верхним уровнем дерева, поиск в котором нам нужно произвести. Для начала мы сравним биты, заданные для оси z узла, и биты, представляющие слои, в которых мы ведем поиск. Если соответст- вие найдено, то мы знаем: в этом узле есть члены, которые могут лежать в нашем про- странстве поиска. Выясним, как расположены все члены этого узла по отношению к про- странству поиска, найдем объекты, которые с ним пересекаются, и перейдем на уровень потомков, чтобы повторить этот процесс. Найдя в узлах объекты, которые пересекают наше пространство пойска, внесем их в связанный список, предназначенный для хранения результата. Когда процесс полностью завершится, в связанном списке будут находиться все элементы квадрадерева, которые пересекают пространство поиска.
116 Глава 5 Поддержка z-масок квадрадеревьев требует дополнительных усилий при добавлении, удалении или перемещении объектов по дереву. Когда объект добавляется в узел или уда- ляется из него, родительские узлы должны быть уведомлены об этом с передачей сообще- ния вверх по цепочке. Это дает им возможность перенастраивать собственные объединен- ные z-маски, поддерживая их актуальность. Класс узла квадрадерева cQuadTreeNode содержит пару функций обработки этих уведомлений. Обе функции, descendantMem- berAdded и descendantMemberRemoved, показаны в листинге 5.1. ЛИСТИНГ 5.1. Функции уведомления для поддержки актуальности z-масок узлов квадрадерева void cQuadTreeNode::rebuildZMask() { // сбросим совокупную z-маску до маски, // определяемой лишь нашими локальными членами m_zMask = m_zLocalMask; // сложим маски потомков for (int i=0;i<4;++i) { if (m_pChildNode[i]) { m_zMask.setFlags(m_pChildNode[i]->zMask()); } } } void cQuadTreeNode::descendantMemberAdded(u32Flags zMask) { // обновим маску zMask m_zMask.setFlags(zMask); // уведомим родителя о добавлении члена if (m_pParentNode) { m_pParentNode->descendantMemberAdded(zMask); } } void cQuadTreeNode::descendantMemberRemoved() { // обновим маску zMask rebuildZMask(); // уведомим родителя об удалении члена if (m_pParentNode) < m_pParentNode->descendantMemberRemoved(); }
УПРАВЛЕНИЕ МИРОМ 117 Медленный поиск в квадрадеревьях Медленного поиска по квадрадеревьям всегда следует избегать, однако, несмотря на это, бывают случаи, когда необходима более тщательная проверка, чем простое обнару- жение пересечений с ограничивающим прямоугольником: Самый интересный пример связан с применением квадрадерева для отыскания набора объектов в поле зрения камеры. Как уже говорилось в этой главе, поле зрения камеры есть множество из шести плоско- стей, которые обозначают видимое через нее пространство. Поле зрения камеры плохо отображается на прямоугольники со сторонами, парал- лельными координатным осям. Его пирамидальный характер ведет к образованию прямо- угольника куда большего по объему, чем исходное поле. Если поиск по квадрадереву основан исключительно на этом прямоугольнике, то к результатам будет отнесено гораздо больше объектов, чем действительно необходимо. Это неплохо, если мы стремимся к быстрому поиску, но если ложно положительные ответы понижают производитель- ность, то мы обязаны провести реальную проверку на принадлежность, чтобы миними- зировать множество результатов. В этих случаях единственная альтернатива заключается в том, чтобы встроить фактическую геометрию поля зрения камеры в алгоритм поиска в квадрадереве, препятствуя добавлению в число результатов поиска тех объектов, которые лежат за пределами границ поля зрения. Проверка вхождения в поле зрения нужна не всегда, поэтому оставим эту возмож- ность в качестве опции. Так, пользуясь простыми методами визуализации, нередко более эффективным бывает произвести быстрый поиск по квадрадереву, позволив ряду внеэкранных объектов пройти по конвейеру рендеринга. Даже притом что рендеринг этих объектов будет совершенно не нужен, скорость их отображения окажется гораздо выше, чем скорость поиска с участием поля зрения. При более сложном характере визуализации верным будет обратное утверждение. В случае, когда в рендеринге задействованы слож- ные шейдеры, лучше потратить время на тщательный поиск и сократить количество ото- бражаемых элементов. - Чтобы упростить эти действия, нужно сначала создать объект данных, пред- ч'———ставляющий поле зрения камеры. Класс eFrustum удовлетворяет нашим потребностям, реализуя множество из шести плоскостей. Эти плоскости и представляют стороны Поля зрения. Для хранения каждой из шести плоскостей как члена,eFrustum используется класс cPlane3d. Он включает в себя данные о каждой плоскости в виде стан- дартного уравнения. Впоследствии этот объект может использоваться как необязательный параметр при поиске в квадрадереве. Детали реализации класса cPlane3d смотрите в папке с исходным кодом геометрических подпрограмм на прилагаемом компакт-диске. Класс eFrustum имеет удобную функцию-член, способную извлечь плоскости поля зрения из матрицы текущей проекции камеры. Такая прямолинейная операция делает соз- дание объекта cFrustum простым и легким занятием. Этот метод извлечения плоскостей
118 Глава 5 создали Гил Грибб (Gil Gribb) и Клаус Хартман (Klaus Hartmann), описавшие процесс ка/с для DirectX, так и для OpenGL [Gribb]. В своей работе авторы показали, что плоскости поля зрения могут извлекаться из любой матрицы камеры путем несложного процесса, приведенного в листинге 5.2. Заметим, что пространство координат извлеченных плоскостей идентично простран- ству координат, описанному матрицей камеры. Так, если матрица реализует отображение мирового пространства в пространство камеры, извлеченные плоскости поля зрения будут заданы в мировом пространстве координат. Если ряд матриц объединен для получения матрицы преобразования пространства модели в пространство камеры, то извлеченные плоскости поля зрения окажутся в пространстве модели. Это свойство и наделяет метод Грибба - Хартмана очень большими возможностями. ЛИСТИНГ 5.2. Извлечение ЗВ-плоскостей из произвольной матрицы камеры inline void eFrustum::extractFromMatrix( const cMatrix4x4& matrix, bool normalizePlanes) { // Левая плоскость отсечения leftPlane.normal.x = matrix._14 + matrix._11; leftPlane.normal.y = matrix._24 + matrix._21; leftPlane.normal.z = matrix._34 + matrix._31; leftPlane.distance = matrix...44 + matrix._41; // Правая плоскость отсечения rightPlane.normal.x = matrix._14 - matrix._ll; rightPlane.normal.у = matrix._24 - matrix._21; rightPlane.normal.z = matrix.„34 - matrix.„31; rightPlane.distance = matrix.„44 - matrix._41; // Верхняя плоскость отсечения topPlane.normal.x = matrix._14 topPlane.normal.у = matrix.„24 topPlane.normal.z = matrix.„34 topPlane.distance = matrix._44 matrix._12; matrix.„22; matrix._32; matrix._42; // Нижняя плоскость отсечения bottomPlane.normal.x = matrix._14 + matrix._12; bottomPlane.normal.у = matrix.„24 + matrix._22; bottomPlane.normal.z = matrix.„34 + matrix._32; bottomPlane.distance = matrix.„44 + matrix.„42;
УПРАВЛЕНИЕ МИРОМ 119 // Ближняя плоскость отсечения nearPlane.normal.х = matrix._13; nearPlane.normal.у = matrix._23; nearPlane.normal.z = matrix._33; nearPlane.distance = matrix._43; // Дальняя плоскость отсечения farPlane.normal.x = matrix._14 - matrix._13; farPlane.normal.у = matrix._24 - matrix._23; farPlane.normal.z = matrix._34 - matrix._33; farPlane.distance = matrix._44 - matrix._43; // нормировать плоскости поля зрения нужно // отнюдь не всегда. В базовых проверках на // обнаружение пересечений могут использоваться // и ненормированные плоскости. if (normalizePlanes) { leftPlane.normalize(); rightPlane.normalize(); topPlane.normalize(); bottomPlane.normalize(); nearPlane.normalize(); farPlane.normalize(); } } Чтобы определить, видны ли объекты в поле зрения камеры, воспользуемся набором из шести тестов типа «плоскость - прямоугольник». Эти тесты относят прямоугольник, стороны которого лежат параллельно осям, либо к числу находящихся в одном из полу- пространств, разделяемых этой плоскостью, либо к числу пересекающих саму плоскость. Все плоскости, извлеченные при помощи исходного кода, приведенного в листинге 5.2, содержат нормали к поверхности, которые указывают внутрь поля зрения. Это значит, что поле зрения камеры описано как объем, полученный объединением положительных полу- пространств каждой плоскости. Тогда если прямоугольник лежит в отрицательном полу- пространстве плоскости поля зрения, то мы знаем, что он располагается вне его. Для реализации теста типа «плоскость - прямоугольник» опишем сначала тест, который определяет, в каком полупространстве относительно плоскости находится прямо- угольник. Затем этот тест можно будет использовать вместе со сторонами поля зрения камеры для получения результата. Код теста «плоскость - прямоугольник», а также теста «поле зрения - прямоугольник» показан в листинге 5.3.
120 Глава 5 ЛИСТИНГ 5.3. Тест «плоскость - прямоугольник» и тест «поле зрения - прямоугольник» enum ePlaneClassifications k_plane_frent = 0, k_plane_back, k_plane_intersect * signedDistance Возвращает имеющее знак расстояние между плоскостью и данной точкой в ЗВ-пространстве. Отрицательные расстояния будут отсчитываться "за" плоскость, то есть в направлении, противоположном направлению нормали к плоскости. inline float cPlane3d::signedDistance( const cVector3& Point) const // плоскость хранится как вектор нормали // и расстояние от начала координат вдоль // этого вектора. return(normal.dotproduct(Point) + distance); inline int planeClassify( const cRect3d& rect, const cPlane3d& plane) cVector3 minPoint, maxPoint; // на основе направления вектора плоскости // построим две точки. minPoint и maxPoint - // две точки прямоугольника, наиболее // удаленные друг от друга в направлении нормали // к плоскости if (plane.normal.х > O.Of) { minPoint.x = (float)rect.xO; maxPoint.x = (float)rect.xl; } else
УПРАВЛЕНИЕ МИРОМ 121 minPoint.х = (float)rect.xl; maxPoint.x = (float)rect.xO; if (plane.normal.у > O.Of) minPoint.у = (float)rect.yO; maxPoint.у - (float)rect.yl; else minPoint.у = (float)rect.yl; maxPoint.у = (float)rect.yO; if (plane.normal.z > O.Of) minPoint.z = (float)rect.zO; maxPoint.z = (float)rect.zl; minPoint.z = (float)rect.zl; maxPoint.z = (float)rect.zO; // найдем имеющее знак расстояние от // плоскости до каждой из точек float drain = plane . signedDistance (minPoint) ; float dmax = plane . signedDistance (ян-fiPoint) ; MAX // если одно значение положительно, I/ другое - отрицатель но, прямоугольник // пересекается с плоскостью if (dmin * dmax < O.Of) { return k_plane_intersect; else if (dmin) { return k_plane_front; return k_plane_back; iline bool eFrustum::testRect(
122 Глава 5 const cRect3d& rect) const if ((planeClassify(rect, leftPlane) == k_plane_back) | (planeClassify(rect, rightPlane) == k_plane_back) | (planeClassify(rect, topPlane) =- k_plane_back) || (planeClassify(rect, bottomPlane) == k_plane_back) || (planeClassify(rect, nearPlane) == k_plane_back) | (planeClassify(rect, farPlane) == k_plane_back)) return false; return true; Теперь, при наличии этих тестов, мы можем выполнить более тщательный поиск по квадрадереву, пользуясь не только прямоугольником со сторонами, расположенными параллельно осям, но и дополнительным полем зрения камеры. Этот поиск гораздо медлен- нее в работе, однако дает более точные результаты. В следующих главах, когда объем работы при визуализации объектов существенно возрастет, мы будем пользоваться дополнительным методом выделения зон с учетом поля зрения камеры, который реализован на квадрадереве и ведет к сокращению количества объектов, которые мы должны обработать. Лучшим пояснением процесса поиска по квадрадереву станет просмотр при- меров исходного кода на прилагаемом компакт-диске. В папке source/gaia содержатся два класса, cQuadTree и cQuadTreeNode, которые включают в себя методы по- иска, описанные в этой главе. Сам исходный код слишком велик для того, чтобы вводить его в книгу в качестве листинга, так что детали реализации квадрадеревьев читатели смогут узнать, если обратятся к снабженному комментариями исходному коду на компакт-диске. Литература [Gribb] Gribb, G., and К. Hartmann. «Fast Extraction of Viewing Frustum Planes from the World-View-Projection Matrix» (работа доступна по адресу www2.ravensoft.com/users/ ggribb/plane%20 extraction.pdf). [Pritchard] Pritchard, M. «Direct Access Quadtree Lookup». Game Programming Gems 2. Charles River Media, Inc., 2001, p. 394-401.
Глава 6 ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ Рассмотрев базовые понятия о разделении мирового пространства, мы готовы начать описание геометрии ландшафта нашего мира. В этой главе мы обратимся к наборам данных, используемым для описания SD-ландшафта, и разнообразным методам его построения. Располагая этими данными, мы создадим полигональный ландшафт и отобразим его, прибегнув к очень примитивному способу. В следующей главе мы изучим альтернативы этому методу, основанному на грубой силе, которые лучше масштабируются и в целом более эффективны. Однако прежде чем эти системы смогут стать объектом нашего изуче- ния, мы, очевидно, должны описать и сформировать данные. Карты высот как входные данные о ландшафте Карта высот - простейшая и самая распространенная схема организации входных данных о геометрии ландшафта. Она представляет собой двухмерный массив значений высот, вписанных в регулярную сетку. Для каждой позиции (х, у) на сетке в ней хранится значение z. Данное z есть высота ландшафта в точке (%, у). Чтобы обойтись малым объе- мом данных во всей таблице, z-информацию обычно хранят в байтах без знака, при этом О обозначает самую малую, а 255 - самую большую высоту данного ландшафта. Все это образует сетку ландшафтных данных, лежащих в диапазоне [0, 255]. Другим полезным аспектом такого подхода является то, что двухмерный массив, состоящий из байтов, идентичен битовой карте в оттенках серого. Каждому пикселу этой карты ставится в соответствие значение от 0 до 255, отражающее оттенок в интервале от черного до белого цвета. Последний эквивалентен диапазону значений высот [0, 255], который мы хотим сохранить как высотную информацию о ландшафте. Выбор битовых карт в оттенках серого как метода хранения наших ландшафтных данных значит, что мы можем легко визуализировать ландшафт как битовую карту. Пример,карты высот в оттен-
124 Глава 6 ках серого показан на рис. 6.1. Темные области изображения указывают на низкие участки ландшафта, светлые области - на высокие. Применение битовых карт как входных данных дает возможность в качестве средств построения ландшафтов пользоваться программами рисования. Мы можем просто изобра- зить высоты ландшафта оттенками серого и сохранить битовую карту для загрузки в наше ядро. К тому же мы получаем возможность работы с таким источником ландшафтных данных из реального мира, как информация Геологической службы Соединенных Штатов (USGS, United States Geological Survey), для доступа к которой достаточно воспользо- ваться открытыми программами преобразования. Рис. 6.1. Пример ландшафтной карты высот. Битовая карта дает возможность увидеть ландшафт сверху. Низкие участки показаны темными пикселами, более высокие - светлыми Служба USGS выдает данные о ландшафте Соединенных Штатов в формате, известном как DEM (Digital Elevation Model, цифровая модель высотных отме- ток). DEM-файлы имеют много форматов, а их разрешение может варьироваться от 10 до 90 метроН. Для отображения ландшафтов низких высот, таких, к числу которых отно- сится наш, лучше использовать более высокое разрешение - 10 метров на точку. Так или иначе, сам формат файлов DEM выходит за рамки тематики этой книги. Вместо разговора о нем мы предлагаем служебную программу, находящуюся на компакт-диске и способную производить конверсию DEM-файлов, а также других источников информации о панд- шафте реального мира, в изображения карт высоты. Эта программа 3DEM производства Visualization Software LLC является свободно рас- пространяемой утилитой, которая может загружать файлы данных, предоставленных USGS, полученных с космической станции NASA Mars Orbiter, спутников Земли, и ланд-
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 125 шафтную информацию Национального управления по исследованию океанов и атмосферы (NOAA, National Oceanic and Atmospheric Administration). Данные о высотах могут без труда извлекаться из этих файлов и переводиться в изображение в оттенках серого, которое используется в нашем ядре. Ссылка на главную страницу сайта компании Visualization Soft- ware LLC приведена в списке рекомендуемой литературы в приложении D «Рекомендуемая литература». Там же можно найти более подробную информацию об источниках данных реального мира и файлах формата DEM. Также в приложении D даны ссылки на файлы полученных USGS ландшафтных данных, находящиеся в свободном доступе. Процедурные карты высот Если поле высот не укладывается в рамки битовой карты в оттенках серого, это может стать основанием для того, чтобы в его создании использовать собственный исходный код, а не программы рисования или данные реального мира. Первая стратегия поведения, которая приходит на ум, - это обычная генерация случайного набора значений поля высот. Она даст свой результат, но полученный в итоге ландшафт будет нереалистичной хаотиче- ской смесью несвязанных между собой высотных значений. Нам же, напротив, необходим метод получения случайных значений высот, основанный на нескольких простых прави- лах, управляющих внешним видом всего ландшафта. За годы работы было описано множество методов генерации случайных карт высоты, и большинство из них сводилось к единственному простому условию: создать случайный набор значений и фильтровать его значения до тех пор, пока ландшафт не станет доста- точно гладким. «Достаточно гладкий» - это относительное понятие, в целом соответст- вующее условию, при котором смежные элементы высотной карты содержат значения, отличные на некую величину. Если значения карты высот слишком далеки от своих сосе- дей, результирующий ландшафт содержит глубокие впадины и высокие пики, что в боль- шинстве приложений выглядит далеким от реальных условий. Идея генерации случайных значений высот с последующей фильтрацией результата для уменьшения дельты между соседними значениями сродни идее дать обезьяне кисть, черную и белую краски, после чего размывать рисунок до тех пор, пока он не станет похож на гористую местность. Эта идея работает, но далеко не всегда ведет к получению приличных горных ландшафтов. Есть более простые методы, что выдают случайные значения, но обеспечивают реалистичный вид получаемого ландшафта. Результаты таких «управляемых» методик куда вероятнее будут приятны глазу, чем то, что мы получим от рисующей обезьяны.
126 Глава 6 Смещение средней точки Первым процедурным методом, который мы будем рассматривать, станет рекурсив- ный процесс, именуемый смещением средней точки (midpoint displacement). Согласно этому методу, мы начнем работу с плоской карты высот и будем поднимать и опускать значения с целью создать случайный ландшафт. Вместо присвоения каждому пикселу карты высот случайного значения безо всякой на то причины, мы прежде всего поделим изображение на четыре квадранта и настроим его углы. Затем интерпретируем каждый квадрант как новое изображение и повторим процесс делением квадранта на четыре более мелкие зоны и установкой высот углов этих зон. По мере рекурсивного спуска мы будем уменьшать область карты, на которой поднимаем или опускаем углы. Сказанное лучше пояснить на картинке. Рис. 6.2 служит иллюстрацией этапов про- цесса. В ходе создания ландшафта мы будем использовать вещественные значения от О до 255. Вещественные значения применяются нами для большей точности построения, а диапазон [0, 255] выбран для того, чтобы итоговый результат мог быть снова переведен в 8-битные значения серого. На каждом этапе мы будем генерировать случайные значения в фиксированном диапазоне и пользоваться ими для смещения точек на карте высот. На первом этапе диапазон покроет все множество значений от -128 до 128. Текущий размах промежутка будем называть дельтой, тогда диапазон в целом будет составлять [- дельта, дельта}. Слева на рис. 6.2 все четыре угла карты высот установлены случайным образом. Затем изображение делится на четыре квадранта, что и показывает пунктир. Это создает пять новых узлов, показанных пронумерованными точками изображения. Базовое значе- ние в каждой точке мы вычислим как среднее значений узлов, с которыми она связана. Например, базовое значение точки 1 мы примем равным среднему значений, находящихся в угловых точках А и В Продолжив работу с точкой номер 1, сместим ее, используя случайное значение, выбранное в диапазоне [- дельта, дельта}, и записав новое значение опять в эту же точку. Далее в том же диапазоне мы сгенерируем случайные значения смещений точек 2, 3 и 4. Точка 5 немного отличается тем, что ее базовое значение находится как среднее всех четырех углов. В остальном процесс будет тем же: расчет случайного значения в диапазоне [- дель- та, дельта} и применение его в качестве смещения относительно базового значения в этой точке. Когда все пять точек окажутся смещены, мы перейдем к следующему этапу процесса. На следующем этапе мы перейдем в один из квадрантов, построенных на предыду- щем этапе, и повторим процесс целиком. Множество новых квадрантов строится так, как показано на рис. 6.2 справа. Найдем базовые значения в этих точках, используя углы квадранта, и сместим их с применением случайных значений. Однако чтобы придать синтезу ландшафта характер управляемого процесса, умножим размах диапазона дельта
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 127 на масштабную величину. Эта величина - скажем, шероховатость (roughness) - значение от 0 до 1, каждый раз сужающее наш диапазон при умножении на него значения дельта. Об этом говорит формула 6.1. Дельта - Дельта * Шероховатость (6.1) Рис. 6.2. Два этапа работы метода смещения средней точки Идеальное значение параметра шероховатость равно 0,5; на каждом этапе оно сокра- щает случайный диапазон вдвое. Настройка значения шероховатости влияет на смену высот ландшафта по мере нашего углубления в рекурсивный процесс. Большие значения шероховатости ведут к созданию более хаотического ландшафта, тогда как с приближе- нием шероховатости к нулю ландшафт становится все более гладким. Процесс завершен, когда все значения карты высот построены; в этот момент мы можем перевести полученные значения в ландшафтную геометрию или преобразовать их в целые числа для сохранения как битовой карты в оттенках серого. Четыре примера карт высоты, созданных по этой методике с различными значениями шероховатости, пока- заны на рис. 6.3. Шум Перлина Наше обсуждение процедурного синтеза ландшафтов было бы не полным без упомина- ния функций шумов. Лучшая из всех функций шума была введена в 1983 г. Кеном Перлинем (Ken Perlin) [Perlin 1]. Перлин предложил функцию для порождения случайных значений, которая стала основой почти всех фильтров генерации мраморных, деревянных поверх- ностей и шумов, встроенных в программы рисования и пакеты ЗП-рендеринга. В 1997 г. за вклад своей работы по процедурным текстурам в индустрию кино с момента первой публикации в середине 1980-х гг. Перлин стал лауреатом премии «Оскар».
128 Глава 6 Рис. 6.3. Heibipe примера карт высоты, созданных методом смещения средней точки. По часовой стрелке, начиная с верхнего левого угла, созданные изображения имеют параметр уменьшения дельты, равный 065,0,75,0,85 и 0,95 Шум Перлина можно рассчитать для п измерений, однако в своем обсуждении мы остановимся на двухмерной реализации этой методики. Кроме того, мы предложим слегка упрощенную версию исходной функции шума Перлина. В основе своей 2Б-шум Перлина является фактически интерполяцией нормальных векторов сетки, поэтому мы сосредо- точимся как раз на этой части методики. На рис. 6.4 мы видим карту высот, разбитук на образующие сетку квадраты. Изображение - вне зависимости от диапазона значений его элементов — полностью накрывается сеткой, представляющей диапазон вещественных чисел. Так, на рис. 6.4 мы создаем шум на сетке, представляющей по всему изображению значения между 0 и 4. Каждое целое число порождает линию сетки, а значит, все стороны каждого квадрата последней имеют длину, равную одной единице. Выбранный масштаб влияет на слож- ность шума. Большее число квадратов на сетке изображения создает более «плотно упакс ванный» шум, подобный белому шуму на экране плохо настроенного телевизора. Мень- шее число квадратов на сетке порождает «клубящийся» шум, внешне похожий на облака.
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 129 В каждой точке на сетке строится случайный вектор нормали. Это обычный двухмерный вектор единичной длины, который указывает в случайном направлении в пределах каждого из квадратов. Традиционный способ создания таких векторов - организация справочной таблицы из 256 векторов, которые охватывают полный крут, и последующий случайный выбор одного из них для каждой точки на сетке. Это гарантирует случайное распределение векторов, которые могут с равной вероятностью указывать в любом направлении. Рис. 6.4. Задание функции шума Перлина нормальными векторами в каждой точке сетки изображения Найдем для каждого пиксела изображения ту из ячеек сетки, где он находится. Так мы построим значение, которое основано исключительно на данных этой ячейки. Сле- дующий шаг - создать четыре диагональных вектора, соединяющих углы ячейки с теку- щим пикселом, как показано на рис. 6.5. Каждый угол ячейки сетки теперь является базой для двух векторов - случайного еди- шчного вектора и вектора в направлении пиксела, который мы стремимся построить. Для каждой пары таких векторов найдем скалярное произведение. Оно даст нам скалярное значение высоты каждого из углов сетки. Далее мы сможем объединить эти четыре значе- шя и найти высоту пиксела, который хотим сгенерировать. Делать это можно по-разному, получая различные результаты, однако чаще всего используется взвешенная интерполя- ция четырех значений с учетом близости текущей позиции к каждому углу ячейки сетки.
130 Глава 6 Рис. 6.5. Расчет значения высоты пиксела при помощи нормалей к сетке и векторов, соединяющих точки сетки с расчетной позицией Имея четыре значения, по одному на каждый угол квадрата, объединим их, выполнив три операции тотального смешивания. Для начала мы должны вычислить веса смешива- ний на основании нашего положения в единичном квадрате. Для этого воспользуемся уравнением 6.1, заменив t своими координатами х и у, найденными относительно сетки. w=6/5-15/4+10/3 (6.1) Это уравнение может быть отличным от других толкований шума Перлина. Он из- начально описал собственный метод, пользуясь уравнением 6.2. Более быстрый при вычис- лении, исходный метод был склонен к образованию артефактов в окончательном резуль- тате. В последующей публикации [Perlin2] Перлин описал причины возникновения арте- фактов и представил уравнение 6.1, помогающее сократить их появление. Ввиду этого раз- ночтения можно найти много печатных и сетевых источников, авторы которых используют то или иное из уравнений. В нашем исходном коде ради эксперимента реализованы оба. w = Зг2 - 2Г3 (6.2) Пользуясь значением веса по оси х и уравнением 6.3, смешаем верхнюю пару значе- ний в углах квадрата. Результат этого уравнения v зависит от значения веса, которое сле- дует из уравнения 6.1, и двух угловых значений са и cb. Та же процедура затем повторя- ется и для двух значений, находящихся в нижних углах. Наконец, результаты двух смеши- ваний и вес по оси у участвуют в третьем, производимом согласно этому же уравнению смешивании значений. Итоговый результат - значение высоты данного пиксела в диапа- зоне между 0 и 1, которое затем масштабируется с учетом желаемого диапазона оттенков
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 131 серого и сохраняется в битовой карте. Примеры результатов расчета шума Перлина пока- заны на рис. 6.6, v = cc(w) + cb(l -w) (6 3) Рис. 6.6. Примеры изображений шума Перлина, созданные при разных размерах сетки, различном числе октав и разных значениях параметра уменьшения Чтобы закодировать свою собственную функцию шумов Перлина, мы выделим ее как отдельную подпрограмму, которую сможем вызывать для каждого пиксела высотной карты. Не организуя на первом шаге набор нормалей к вершинам сетки, опишем функцию, которая, учитывая угол сетки (х, у), будет извлекать псевдослучайный вектор из нашей таблицы, где содержатся векторы. Тем самым нам не придется генерировать и хранить нормали к точкам на сетке. Каждый раз, когда нам понадобится нормаль, мы сможем заново построить ее исходя из координат точки сетки с гарантией, что нормаль неизменна при каждом обращении к ней. Широко применяемый для этого метод заключается в определении набора из 256 нормалей, речь о которых шла выше, как множества потенциальных векторов. Далее стро- ится вторая справочная таблица, которая содержит 256 случайных значений в диапазоне 5*
132 Глава 6 [0,255]. Это - вторичная индексная таблица. При наперед заданном множестве из 256 век- торов (V), вторичной справочной таблице (Т) и точке (х, у) на сетке воспользуемся для выбора нормального вектора функцией вида: cVector2 RandomGridNormal( unsigned char x, unsigned char y) { return V[ (T[x] + Tty]) % 255 ]; }; Эта функция использует координаты х и у как индексы вторичной справочной таб- лицы Т. Из нее мы и считываем и складываем друг с другом два случайных значения. Для возврата в диапазон [0, 255] результат берется по модулю 255. Новое значение служит индексом в пуле векторов V, откуда мы считываем нормальный вектор. В итоге мы имеем нормальный вектор, кажущийся случайным, но одинаковым образом вычисляемый каждый раз при неизменных значениях х и у. Это устраняет необходимость хранения большого набора нормалей к сетке изображения, поскольку мы можем быстро восстано- вить любой вектор, как только он нам понадобится. Теперь мы готовы писать функцию шума. Для данной точки изображения (х, у) и дан- ного масштаба общего шумового шаблона функция шума определяет четыре окру- жающих точку сетки нормали, после чего рассчитывает итоговое значение, полученное интерполяцией этих нормалей. Опишем весь процесс в классе cPerlinNoise, показан- ном в листинге 6.1. ЛИСТИНГ 6.1. Класс, реализующий шум Перлина class cPerlinNoise { public: enum { k_tableSize = 256, k_tableMask = k_tableSize-l, }; cPerlinNoise(); -cPerlinNoise(); float noise(int x, int y, float scale); private: cVector2 m_vecTable[k_tableSize];
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 133 unsigned char m_lut[k_tableSize]; // Private-функции класса... void setup(); const cVector2& getVec(int x, int y)const; }; void cPerlinNoise::setup() { float step = 6.24f / k_tableSize; float val=0.0f; srand(timeGetTime()); for (int i=0; i<k_tableSize; ++i) { m_vecTable[i].x = sin(val); m_vecTable[i].y = cos(val); val += step; m_lut[i] = rand() & k_tableMask; } } const cVector2& cPerlinNoise::getVec( int x, int y)const { unsigned char a = m_lut[x&k_tableMask]; unsigned char b = m_lut[y&k_tableMask]; unsigned char val = m_lut[(a+b)&k_tableMask]; return m_vecTable[val]; } float cPerlinNoise::noise(, int x, int y, float scale) cVector2 pos(x*scale, y*scale); float XO = floor(pos.x); float XI = XO + l.Of; float YO = floor(pos.y); float Yl = YO + l.Of; const cVector2& vO = getVec((int)XO, (int)YO); const cVector2& vl =
134 Глава 6 getVec((int)ХО, (int)Yl); const cVector2& v2 = getVec((int)XI, (int)YO); const cVector2& v3 = getVec((int)XI, (int)Yl); cVector2 dO(pos.x-XO, pos.y-YO); cVector2 dl(pos.x-XO, pos.y-Yl); cVector2 d2(pos.x-Xl, pos.y-YO); cVector2 d3(pos.x-Xl, pos.y-Yl); float hO = dotProduct(dO, vO); float hl = dotProduct(dl, vl); float h2 = dotProduct(d2, v2); float h3 = dotProduct(d3, v3); float Sx,Sy; Исходное уравнение Перлина' было быстрее, но в отдельных случаях вело к появлению артефактов Sx = (3*powf(dO.х,2.Of)) - (2*powf(dO.x, 3.Of) ) ; Sy = (3*powf(dO. y,2.Of)) - (2*powf(dO.y,3.Of)) ; // исправленное уравнение смешивания // считается более совершенным, однако // требует больше времени на расчеты Sx = (6*powf(dO.х,5.Of)) - (15 *powf(dO.x,4.0 f)) + (10*powf(dO.x, 3.Of) ) ; Sy = (6*powf(dO.y,5.Of)) - (15*powf(dO.y,4.Of) ) + (10*powf(dO.y,3.Of) ) ; float avgXO = hO + (Sx*(h2 - hO)); float avgXl = hl + (Sx*(h3 - hl)); float result = avgXO + (Sy*(avgXl - avgXO)); return result; Преимущества шума Перлина становятся очевиднее, когда несколько результатов объединяются, формируя окончательное изображение. Процесс объединения множества функций шума именуют фрактальным броуновским движением (fBM, fractional Brownian Motion), однако его применение намного проще, чем следует из такого названия.
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 135 Представьте себе две функции шума Перлина, реализованные в различном масштабе. Так, мы могли бы создать две карты шумов Перлина, масштаб одной из которых вдвое превосходит масштаб другой. Простое сложение результатов позволит заменить один результат другим, создав полностью новый шум. На рис. 6.7 показаны результаты сложения двух карт шумов. Отдельные карты шума носят название октав (octaves), так как одна из Них имеет вдвое больший масштаб, чем другая. Объединение октав путем их сложения, перемножения и т. д. ведет к образова- нию новых шаблонов шума. При добавлении к результату на рис. 6.7 более мелкого шаб- лона 2-й октавы он детализирует октаву номер 1. Чтобы создать качественный ландшафт, как правило, требуется много октав, которые дополняют большие холмы, образованные октавами крупных масштабов, более мелкими элементами. Рис. 6.7. Пример изображения шума Перлина, полученный сложением двух октав Обработка данных высотных карт Прежде чем мы сможем использовать наш ландшафт, он должен быть переведен из карты высот в оттенках серого цвета в полигональный меш. Чтобы наглядно это пред- ставить, изобразим карту высот как сетку высотных значений. Эта сетка (grid) высот будет напрямую преобразована в меш вершин (vertex mesh), который мы построим, х- и у-ко- ординаты каждого элемента карты высот станутх- иу-координатами вершин сетки. Значе- ние цвета, занесенное в каждый пиксел, станет z-координатой вершины. Эти значения мы должны привести к желаемым размерам и высоте ландшафта; в остальном процесс очевиден. Считывая значения серого в диапазоне [0, 255], мы попросту масштабируем их до максимального желаемого значения высоты собственного ландшафта. То же относится к (х, у)-координатам вершин. При этом целочисленные координаты пикселов преобра- зуются в реальные ландшафтные координаты. Каждое 2 х 2-множество пикселов организует 2 х 2-множество вершин, образуя два треугольнгка. Занесем данные о каждой вершине в простой список координат (х, у, г). Данные о треугольнике запишем как множество из трех индексов в списке вершин,
136 Глава 6 которое характеризует те вершины, что участвуют в образовании каждого треугольника. Позднее эти два списка данных станут нашим вершинным и индексным буферами. Помимо координат вершин найдем нормаль к поверхности каждого треугольника, взяв векторное произведение двух лежащих на сторонах нормированных векторов, как пока- зано на рис. 6.8. Рис. 6.8. Расчет нормали к поверхности каждой из граней сетки Когда нормали всех граней будут рассчитаны, мы сможем объединить их в нормали вершин. Если мы попытаемся визуализировать ландшафт, пользуясь нормалями к граням, он будет освещен достаточно грубо. Чтобы создать более гладкую модель освещения, мы должны вычислить нормаль к каждой вершине как среднее значение нормалей, построенных к смежным с ней граням. Нормали граней, которым принадлежит каждая из вершин, должны быть усреднены и нормированы повторно. Так для каждой вершины создается нормаль, готовая к применению в расчетах освещения. Теперь у нас есть информация о положении каждой вершины и о нормали к поверхности, которая вкупе и составляет минимум, необходимый, чтобы осуществить рендеринг ландшафта. Послед- ний шаг-загрузка данных в буфер вершин Direct3D и их применение для рендеринга. Этот процесс показан в листинге 6.2. В тексте функции buildHeightAndNormalTables, приведенной в листинге 6.2, вы обратите внимание на то, что для расчета нормалей к вершинам ландшафта мы выбира- ем существенно ускоренный метод. Вместо самостоятельного расчета векторных произве- дений и усреднения результатов мы даем возможность сделать эту работу служебной биб- лиотеке D3DX. D3DX содержит функцию D3DXComputeNormalMap, способную преобра- зовать текстуру, содержащую высотную информацию в оттенках серого, в соответст- вующую карту нормалей. Координаты каждой нормали к поверхности заносятся в красный, зеленый и синий цветовые каналы результирующей текстуры. Чтобы частично избавиться
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 137 от этой работы, воспользуемся данной функцией для преобразования текстуры, содержа- щей данные о высоте, в текстуру, содержащую найденные нормали, после чего просто извлечем полученный результат. Наш класс-контейнер текстур DirectX cTexture имеет эту функциональность благо- даря наличию в нем функции-члена generateNormalMap. В листинге 6.2 эта функция служит для генерации текстуры с нормалями к поверхностям по исходным данным высот- ной карты. Извлечение же нормальных векторов состоит в считывании каждого пиксела результирующей текстуры и повторном переводе значений каждого цветового канала из начального диапазона [0, 255] в пространство единичных векторов [-1, 1]. Листинг 6.2. Создание таблиц высотных отметок и нормалей к поверхностям по карте высот // Здесь мы преобразуем данные карты высот // в вещественные значения высоты и нормали к поверхностям, // каждая из них сохраняется в таблице значений // в одном из классов ландшафтной системы // - void cTerrain ::buildHeightAndNormalTables(cTexture* pTexture) ( safe_delete_array(m_heightTable); safe_delete_array(m_normalTable); int maxY = m_tableHeight; int maxX = m_tableWidth; int x,y; m_heightTable = new float[maxX*maxY]; m_normalTable = new cVector3[maxX*maxY]; // прежде всего построим таблицу высот D3DL0CKED_RECT lockedRect; if(SUCCEEDED( pTexture->getTexture()-> LockRect(0, &lockedRect, 0, D3DLOCK—READONLY))) { uint8* pHeightMap = (uint8*)lockedRect.pBits; for(y=0; y<maxY; ++y) { for(x=0; xcmaxX; ++x) { int iHeight = pHeightMap[(y*lockedRect.Pitch)+(x*4)]; float fHeight =
138 Глава 6 (iHeight * m_mapScale. z) + m_worldExtents.zO; m_heightTable[(y*maxX)+x] = fHeight; } } pTexture->getTexture()->UnlockRect(0) ; } // создадим текстуру карты нормалей cTexture temp; temp.createTexture( m_tableWidth, m_tableHeight, 1, 0, D3DFMT_A8R8G8B8, D3DPOOL_SCRATCH); // какой смены масштаба требуют эти нормали? float scale = (m_tableWidth * m_worldExtents.sizeZ()) /m_worldExtents.sizeX() ; // Используем D3DX для перевода карты высот // в текстуру нормалей к поверхностям temp.generateNormalMap( pTexture->getTexture(), D3DX_CHANNEL_RED, 0, scale); // затем произведем считывание нормалей // и сохраним их во внутренней структуре данных if(SUCCEEDED(temp.getTexture()->LockRect( 0, klockedRect, 0, D3DLOCK_READONLY))) { uint8* pNormalMap = (uint8*)lockedRect.pBits ; for(y=0; y<maxY; ++y) { for(x=0; x<maxX; ++x) { int index = (y*lockedRect.Pitch)+(x*4); cVector3 normal; normal.z = pNormalMap[index+0] - 127.5f; normal.у = pNormalMap[index+1] - 127.5f;
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 139 normal.х = pNormalMap[index+2] - 127.5f; normal.normalize(); m_normalTable[(y*maxX)+x] = normal; } } temp.getTexture()->UnlockRect(0) ; } temp.releaseTexture(); } Базовые классы ландшафтной геометрии Для работы с базовым вариантом ландшафта, а также его отображения воспользуемся двумя классами. Один класс - cTerrain представляет ландшафт целиком. Для упроще- ния выделения зон ландшафта, предназначенных для вывода на экран, весь ландшафт де- лится на участки с выравниванием по сетке. Эти участки представлены вторым классом - cTerrainSection. Два этих класса и образуют тот самый фундамент, которым мы будем пользоваться во всех будущих методах работы с ландшафтом. Как было показано в листинге 6.2, класс cTerrain содержит информацию о высоте, и нормалях к поверхностям всего ландшафта в виде набора таблиц. Это делает объект cTerrain центральным хранилищем данных, касающихся ландшафта. Он имеет функ- ции прямого доступа к этим таблицам или интерполяции элементов этих таблиц при необ- ходимости получить данные большего разрешения, чем те, что могут храниться в таблице. Эти функции докажут свою полезность, когда в главе 9 «Методы текстуризации» мы начнем покрытие ландшафта текстурой. Рендеринг ландшафта в целом как одного гигантского множества треугольников неэф- фективен, поскольку ландшафт может выходить далеко за пределы видимости камеры во всех направлениях. Чтобы работать в рамках нашей системы отображения, поделим ландшафт на поддающиеся контролю участки, представленные классом cTerrainSec- tion. К примеру, для представления ландшафта из 256 х 256 вершин мы можем создать cTerrain, который будет хранить весь набор данных. Затем объект cTerrain поделит ландшафт на участки по 32 х 32 вершины и создаст в общей сложности 64 объекта cTer- rainSection, которые и будут представлять каждый из этих участков. Класс cTerrainSection является прямым потомком класса cSceneObject, описан- ного в главе 4 «Обзор движка Gaia». Это дает возможность ввести любой объект cTer- rainSection в состав квадрадерева и пропустить его через конвейер рендеринга, как будто он является отдельной моделью. Правда, в отличие от объектов cSceneObject мы не хотим сохранять весь набор геометрии для каждого из cTerrainSection. В самом
140 Глава 6 деле, пользуясь вершинным и индексным буферами, мы видим, что большое количество информации можно один раз сохранить внутри cTerrain и прибегать к ней при рен- деринге каждого объекта cTerrainSection. Индексные буферы в ландшафтной геометрии Самый очевидный кандидат на совместное применение - это индексный буфер. Если все объекты cTerrainSection имеют одинаковые размеры и содержат одинаковое число вершин, мы можем создать один объект типа «индексный буфер», пользуясь им для рендеринга любых участков ландшафта. Только поэтому класс cTerrain содержит единственный объект типа clndexBuf fer. Ради дальнейшего упрощения в классе clndexBuffer реализована функция-член, которая строит индексный буфер, оптимизированный для сеток вершин, таких, как наш участок ландшафта. Эта функция приведена в листинге 6.3. При заданном наборе параметров регулярной сетки с данными о вершинах функция createSingleStripGrid класса clndexBuffer организует индексный буфер, содержащий единый слой треугольни- ков, которые покрывают всю сетку. Более подробную информацию о слоях треугольников и об эффективности, которой они позволяют достичь при рендеринге, см. в DirectX SDK. Сам процесс прост. Сначала определяется число горизонтальных слоев, необходимых для покрытия сетки. Затем поочередно организуется каждый слой. Конец каждого слоя соединяется с началом следующего при помощи треугольника, который вырожден и ин- дексирует одну и ту же вершину более одного раза, что превращает его в треугольник нулевой площади. Коль скоро треугольники такого рода не занимают места, то при рен- деринге они не соответствуют никаким пикселам на экране. Зная о том, что связующие треугольники никогда не будут видны, мы можем использовать их для соединения отдель- ных горизонтальных слоев в один объединенный слой. Листинг 6.3. Создание индексного буфера слоев треугольников для адресации вершин в узлах сетки bool clndexBuffer::createSingleStripGrid( uintl6 xVerts, // длина сетки uintl6 yVerts, // высота сетки uintl6 xStep, // число вершин в ячейке (гориз.) uintl6 yStep, // число вершин в ячейке (верт.) uintl6 stride, // число вершин в вершинном буфере (гориз.) uintl6 flags) { int total_strips = yVerts-1; int total_indexes_per_strip = xVerts<<l;
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 141 // общее число индексов равно количеству // слоев, умноженному на число индексов в // одном слое, плюс по одному вырожденному // треугольнику между каждой парой слоев int total-indexes = (total—indexes—per_strip * total_strips) + (total—strips<<l) - 2; unsigned short* plndexValues = new unsigned short[total—indexes]; unsigned short* index = plndexValues; unsigned short start—vert = 0; unsigned short lineStep = yStep*stride; for (int j=0;j<total_strips;++j) { int k=0; unsigned short vert=start_vert; // создадим слой для текущей строки for (k=0;k<xVerts;+ + k) { *(index+ + ). = vert; *(index++) = vert + lineStep; vert += xStep; } start—vert += lineStep; if (j+l<total_strips) { // добавим вырожденный треугольник // для связи со следующей строкой *(index++) = (vert-xStep)+lineStep; *(index++) = start_vert; } } // наконец, используем индексы, созданные нами // выше, для заполнения индексного буфера Direct3D bool result= create(D3DPT_TRIANGLESTRIP, total-indexes, flags, plndexValues); // уничтожим локальные данные и произведем возврат delete [] plndexValues; return result,- }
142 Глава 6 Курьезом листинга 6.3 является применение значения шага для вершин в направлении х и у. Этой функции передается пара параметров (xStep и yStep), которые служат для перехода от вершины к вершине при создании треугольников на сетке вершин. Эта дополнительная возможность не очень-то нужна нам сейчас, однако позднее, организуя уровни детализации ландшафта, мы сочтем данную возможность весьма полезной. Обычно значения xStep и yStep равны 1, но, несмотря на это, в дальнейшем мы сможем увеличить эти значения, чтобы создать индексный буфер, в котором для покрытия cTerrainSection используется меньшее число треугольников и который ссылается на каждую вторую или каждую третью вершину сетки. Подробнее об этом речь пойдет в главе 7 «Ландшафтная система ROAM». Вершинные буферы в ландшафтной геометрии Данные о вершинах ландшафта могут содержать львиную долю хранящейся информации. На первый взгляд кажется, будто каждый участок ландшафта содержит свои собственные вершинные данные. Вероятность найти два участка ландшафта с одинаковой топографией крайне мала, что не позволяет упаковать данные, необходимые нам для рабо- ты. Однако если интерпретировать каждый участок ландшафта как независимую модель, то мы найдем шанс совместно использовать большую часть информации о вершинах. Это позволит нам гораздо эффективнее хранить вершинные данные, тем не менее создавая неповторяющийся ландшафт. Рассмотрим пример ландшафта, в котором каждый экземпляр cTerrainObj ect пред- ставляет подмножество всего ландшафта размером 32 х 32 вершины. В этом примере каждая из вершин имеет позицию, нормаль к поверхности и координаты текстуры. Если эти данные напрямую скопировать из ландшафта, то они будут уникальными в масштабах нашего мира. Однако в том случае, если считать каждый участок моделью, сложенной из 32 х 32 вершин, которую мы переносим на предназначенное ей место в пространстве нашего мира, мы обнаружим, что большинство данных пространства модели полностью повторяется на всех участках ландшафта. Проще говоря, вместо размещения в каждом из объектов cTerrainSection позиций вершин и координат текстур реального мира мы запишем в них х- и у-значения в диапазоне между 0 и 1, которые будут служить как для обращения к вершинам, так и для адресации текстур. В ходе визуализации участка ландшафта мы сменим масштаб координат в диапазоне [0, 1] до желаемого размера, после чего, пользуясь вершинным шейдером, пере- местим этот участок в нужную точку с конкретными мировыми координатами. Каждый из фрагментов текстуры использует при этом один и тот же набор данных об ху-координа- тах позиций и wv-координатах текстур, поэтому мы сможем занести их в объект cTerrain один раз и обращаться к ним при рендеринге любых участков cTerrainSection.
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 143 При этом подходе в каждом cTerrainSection остаются некие уникальные данные, такие, как высоты вершин (позиции по оси z) и нормали к поверхности, однако изъятие из Каждого участка ландшафта данных о положении на плоскости и координатах текстур неплохо сокращает общий объем памяти, необходимой ландшафту. Следствием этого является то, что для рендеринга каждого участка ландшафта должны использоваться два потока вершин: общие данные о вершинах из cTerrain и уникальные, хранящиеся в cTerrainSection. Возвращаясь назад к описанному в главе 4 классу cRenderEntry, мы видим, что наша очередь рендеринга уже обладает всем необходимым для отображения нескольких пото- ков вершин. Хотя в тот момент это могло показаться ненужным, теперь эта возможность определенно становится значимой. Ставя объекты cTerrainSection в очередь на рендеринг, мы добавляем к объекту cRenderEntry оба потока вершин. При наступле- нии реального момента визуализации оба этих потока будут активированы, с тем чтобы вершинный шейдер обращался к ним так, будто они являются одним перемежающимся вершинным буфером. Этот аспект Direct3D помогает в применении множества потоков вершин прозрачным для вершинных шейдеров образом. Весь процесс инициализации классов cTerrain и cTerrainSection для работы с данным ландшафтом демонстрирует листинг 6.4. В верхней части этого листинга дано описание вершин с характеристикой обоих потоков вершинных данных, которые будут использова- ны при рендеринге ландшафта. Далее в листинге приведена функция cTerrain: : create, которая является отправной точкой всего процесса. Эта функция преобразует полученную ею карту высот в данные о вершинах, используя код, показанный в листинге 6.2, после чего создаст отдельные участки ландшафта и породит совместно используемые данные. ЛИСТИНГ 6.4. Процесс настройки объектов cTerrain и cTerrainSection // определения вершин базового ландшафта, // используем два потока вершин. Первый - // единый вершинный буфер, который совместно // используют все участки ландшафта. Второй - // буфер вершин, созданный каждым участком // ландшафта для сохранения локальных данных // о высоте и нормалях static D3DVERTEXELEMENT9 vertex_description[]= { // локальные данные (поток 0) {0, О, D3DDECLTYPE_FLOAT2, D 3 DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 } , {0, 8, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT,
144 Глава 6 D3DDECLUSAGE_TEXC00RD, 0 }, // данные участка (поток 1) (1, О, D3DDECLTYPE_FLOAT1, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 1 }, {1, 4, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_NORMAL, 0 } , D3DDECL_END() }; // // Эта функция является начальной точкой // преобразования карты высот в данные // о вершинах // bool cTerrain ::create( cSceneNode* pRootNode, cTexture* heightMap, const cRect3d& worldExtents, uint8 shift) { bool result = false; m_sectorShift = shift; m_sectorUnits = l<<shift; m_sectorVerts = m_sectorUnits+l; m_pRootNode = pRootNode; m_worldExtents = worldExtents; m_worldSize = worldExtents.size(); m_tableWidth = heightMap->width(); m_tableHeight = heightMap->height(); m_mapScale.x = m_worldSize.x/m_tableWidth; m_mapScale.у = m_worldSize.y/m_tableHeight; m_mapScale.z = m_worldSize.z/255.Of; // преобразуем карту высот в данные, // хранящиеся в локальных таблицах buildHeightAndNormalTables(heightMap); m_sectorCountX = m_tableWidtl»m_sectorShift; m_sectorCountY = m_tableHeighB&m_sectorShift; m_sectorSize.set(
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 145 m_worldSize.x/m_sectorCountX, m_worldSize.y/m_sectorCountY); // организуем объекты вершинного и индексного буферов, // которыми участки будут пользоваться совместно if (buildVertexBuffer()) { if (setVertexDescription()) { if (buildlndexBuffer()) { // построим каждый участок ландшафта result = allocateSectorsО; } } } if(!result) { destroy(); } return result; } // // Эта функция будет формировать // отдельные участки ландшафта // bool cTerrain ::allocateSectors() { m_pSectorArray = new cTerrainSection[ iri_sectorCountX*m_sectorCountY] ; // создадим сами объекты участков for (int y=0; y<m_sectorCountY; ++y) { for (int x=0; x<m_sectorCountJ(; ++x) { cVector2 sectorPos( m_worldExtents.x0+(x*m_sectorSize.x), m_worldExtents.y0+(y*m_sectorSize.y)); cRect2d sectorRect( sectorPos.x, sectorPos.x+m_sectorSize.x, sectorPos.y, sectorPos.y+m_sectorSize.y); uintl6 xPixel = x«m_sectorShift;
146 Глава 6 uintl6 yPixel = y<<m_sectorShift; uintl6 index = (y*m_sectorCountX)+x; if (’m_pSectorArray[index].create( m_pRootNode, this, x, y, xPixel, yPixel, m_sectorVerts, m_sectorVerts, sectorRect)) { return false; } } } return true; } bool cTerrain ::buildVertexBuffer() { cString tempName; tempName.format("terrain_system_%i", this); // создадим буфер вершин, которым участки // будут пользоваться совместно m_pVertexGrid = DisplayManager.vertexBufferPool(). createResource(tempName); cVector2 cellsize( m_sectorSize.x/m_sectorUnits, m_sectorSize.y/m_sectorUnits); cVector2 vert(0.Of,0.Of); sLocalVertex* pVerts = new sLocalVertex[m_sectorVerts*m_sectorVerts]; // заполним поток вершин позициями по х,у и // координатами по uv. Все остальные данные // (высоты и нормали к поверхностям) хранятся в // вершинных буферах каждого участка ландшафта for (int у=0; y<m_sectorVerts; ++у) { vert.set(0.Of, y*cellSize.у); for (int x=0; x<m_sectorVerts; ++x)
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 147 { pVerts[(y*m_sectorVerts)+х].xyPosition = vert; pVerts[(y*ra_sectorVerts)+x].localUV.set( (float)x/(float)(m_sectorVerts-l), (float)y/(float)(m_sectorVerts-l)); vert.x += cellsize.x; } } // теперь, когда мы создали данные, организуем // один из наших объектов-ресурсов типа "буфер // вершин” и занесем в него эти данные bool result = SUCCEEDED( m_pVertexGrid->create( m_sectorVerts*ni_sectorVerts, sizeof(sLocalVertex), 0, pVerts)); safe_delete_array(pVerts); // если создание вершинного буфера было // успешным, то сформируем описание вершин // и внесем его в состав класса данных, // произведем начальную настройку этого описания if (result) { m_pVertexGrid->setVertexDescription( sizeof(vertex_description)/sizeof(D3DVERTEXELEMENT9), vertex_description); } return result; } bool cTerrain::setVertexDescription() { // опишем вершины и поместим // описание в состав хранящего в себе // базовую сетку вершинного буфера bool success = m_pVertexGrid->setVertexDescription( sizeof(vertex_description)/ sizeof(D3DVERTEXELEMENT9), vertex_description); return success;
148 Глава 6 bool cTerrain ::buildlndexBuffer() { cString tempName; tempName.format("terrain_system_%i", this); m_pTriangles = DisplayManager.indexBufferPpol() . createResource(tempName); // создадим индексный буфер, которым все участки // ландшафта будут пользоваться совместно return SUCCEEDED( m_pTriangles->createSingleStripGrid( m_sectorVerts, /./ длина сетки m_sectorVerts, // высота сетки 1, // число вершин в ячейке (гориз.) 1, // число вершин в ячейке' (верт.) m_sectorVerts, // число вершин в вершинном буфере (гориз.) 0) ) ; Рендеринг участков ландшафта Как было сказано выше, конвейер рендеринга, созданный нами в главе 4, уже готов к работе с множеством потоков вершин. Все, что нам остается, - это отправлять отдельные объекты cTerrainSection на рендеринг, после чего по мере поступления запросов ото- бражения с учетом порядка их обрабатывать. Вы помните, что мы построили систему, в ко- торой отдельные объекты cSceneObject ставятся в очередь cRenderQueue, сортируются для оптимального отображения и подвергаются обработке с участием функций обратного вызова. Наши объекты cTerrainSection, порожденные от базового класса cSceneOb- ject, идеально соответствуют этой системе. Когда они находятся внутри квадрадерева, то пользуются перегрузкой стандартной функции рендеринга класса cSceneObject, чтобы включить себя в очередь и по запросу произвести окончательное отображение. Функции-члены, отвечающие за рендеринг объектов класса cTerrainSection, при- ведены в листинге 6.5. Заметим, что хотя все вызовы и направляются объектам cTerrain- Section, они пересылают запрос на отображение обратно родителю cTerrain. Это дает ему возможность пользоваться ресурсами данных, общими для всех cTerrainSection. Активизация всех необходимых для рисования ресурсов, включая оба потока вершин, которые и образуют ландшафтную геометрию, осуществится в функции рендеринга cTerrain, прежде чем будет реально выполнен главный вызов функции рисования.
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 149 Листинг 6.5. Процесс настройки объектов cTerrain и cTerrainSection void cTerrainSection::render() { // потребуем от родителя поставить участок в очередь m_pTerrainSystem->submitSection(this); } void cTerrainSection::rendercallback( cRenderEntry* entry, u32Flags activationFlags) { // потребуем от родителя произвести рендеринг m_pTerrainSystem->renderSection( this, activationFlags, entry); } void cTerrain ::submitSection( cTerrainSection* pSection)const { cEffectFile* pEffectFile = m_pRenderMethod->getActiveEffect(); eSurfaceMaterial* pSurfaceMaterial = m_pRenderMethod->getActiveMaterial(); if (pEffectFile) { profile_scope(cTerrain _submitSection); int total_passes = pEffectFile->totalPasses(); // проверим, нуждаются ли соседние участки в соединении uintl6 sX = pSection->sectorX(); uintl6 sY = pSection->sectorY(); int index = (sY*m_sectorCountX)+sX; for (int iPass=0; iPass<total_passes; ++iPass) { cRenderEntry* pRenderEntry = DisplayManager.openRenderQueue(); pRenderEntry->hRenderMethod = (uint8)pEffectFile->resourceHandle(); pRenderEntry->hSurfaceMaterial = pSurfaceMaterial->resourceHandle(); pRenderEntry->modelType = cRenderEntry::k_bufferEntry; pRenderEntry->hModel =
150 Главаf m_pVertexGrid->resourceHandle(); pRenderEntry->modelParamA = pSection->sectorVertices()->resourceHandle(); pRenderEntry->modelParamB = m_pTriangles->resourceHandle(); pRenderEntry->renderPass iPass; pRenderEntry->object = (cSceneNode*)pSection; pRenderEntry->userData = 0; DisplayManager.closeRenderQueue(pRenderEntry); } } } void cTerrain ::rendersection( cTerrainSection* pSection, u32Flags activationFlags, const cRenderEntry* pEntry)const { cEffectFile* pEffectFile = m_pRenderMethod->getActiveEffeet(); cSurfaceMaterial* pSurfaceMaterial - m_pRenderMethod->getActiveMaterial(); if (pEffectFile) { profile_scope(cTerrain _renderSection); LPDIRECT3DDEVICE9 d3dDevice = TheGameHost.d3dDevice(); // нужно ли активировать метод рендеринга? if (TEST_BIT(activationFlags, k_activateRenderMethod)) { pEffectFile->begin(); } // нужно ли активировать проход при рендеринге? if (TEST_BIT(activationFlags, k_activateRenderMethodPass)) { pEffectFile->activatePass(pEntry->renderPass) ;
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ 151 // нужно ли активировать первичный буфер вершин? if (TEST_BIT(activationFlags, k_activateModel)) { m_pVertexGrid->activate(0,0, true); } // нужно ли активировать вторичный буфер вершин? if (TEST_BIT(activationFlags, k_activateModelParamA)) ( pSection->sectorVertices()->activate( 1,0, false); } // нужно ли активировать индексный буфер? if (TEST_BIT(activationFlags, k_activateModelParamB)) { m_pTriangles->activate(); } // нужно ли активировать материал поверхности? if (TEST_BIT(activationFlags, k_activateSurfaceMaterial)) { pEffectFile->applySurfaceMaterial( pSurfaceMaterial); } // применим эти настройки рендеринга при вызове метода int sectorX = pSection->sectorX(); int sector? = pSection->sectorY(); cVector4 sectorOffset( l.Of, l.Of, m_worldExtents.x0+(m_sectorSize.x*sectorX) , m_worldExtents,y0+(m_sectorSize.y*sectorY)); cVector4 uvScaleOffset( (float)l.Of/(m_sectorCountX+l) , .(float)l.Of / (m_sectorCountY+l) , (float)sectorX, (float)sectorY); pEffectFile->setParameter( cEffectFile: :k_posScaleOffset,
152 Глава 6 (D3DXVECTOR4*)bsectorOffset) ; pEffectFile->setParameter( cEffectFile::k_uvScaleOffset, (D3DXVECTOR4*)buvScaleOffset); // рендеринг!!! HRESULT hr = d3dDevice->DrawIndexedPrimitive( m_pTriangles->primitiveType(), 0, 0, m_sectorVerts*m_sectorVerts, 0, m_pTriangles->primitiveCount()); Демонстрация базового ландшафта • В этой главе мы показали, как, пользуясь целым рядом приемов, создать про- ч~---стую карту высот, а потом преобразовать полученное изображение в информа- цию о вершинах, пригодную для рендеринга. В следующей главе мы изучим более эффек- тивные способы хранения данных об индексах вершин и треугольниках, а также управле- ния ими, пока же уделим пару минут отображению результатов. Рассмотренные нами приемы проиллюстрирует демонстрационная программа chapter6_demo0. Ее (а также исходный код) можно найти на прилагаемом компакт-диске. Пример каркасного меша (wireframe mesh), созданного с применением описанных методов, показан на рис. 6.9. Для простоты ландшафта к нему пока не создано никаких реалистичных текстурных карт. Методы создания карт текстур мы будем рассматривать в главе 9. На данный момент пример программы отображает на весь ландшафт саму карту высот, показывая связь между пикселами изображения и окончательной геометрией сцены. Эту первую демон- страцию можно считать применением «грубой силы» в контексте рендеринга ландшаф- тов. Управления уровнями детализации (LOD) нет, имеется лишь выделение видимых зон с участием квадрадерева. Хотя это может выглядеть непрактичным, многие современные видеокарты высокого класса способны использовать этот метод при удивительно высокой частоте обновления кадров. В следующей главе мы изучим методы сокращения объема отображаемых гео- метрических данных. Это поможет нам добиться более эффективного рендеринга на более функционально ограниченных видеокартах, хотя и может оказаться ненужным в ближайшем будущем. С каждым новым успехом в сфере аппаратного ускорения 3D-rpa- фики мы понимаем, что обычный подход, основанный на «грубой силе», может стано-
ОСНОВЫ ЛАНДШАФТНОЙ ГЕОМЕТРИИ is: виться все более привлекательным. Отсутствие же каких-либо действий по настройке ил г управлению LOD, возлагаемых на ЦП, делает этот подход предпочтительным на те> видеокартах, которые с высокой скоростью способны обрабатывать геометрическук информацию. Как всегда, для отыскания метода, лучше других подходящего для выбран- ной аппаратной платформы, необходимо поставить эксперимент. Рис. 6.9. Базовый меш из треугольников, созданный по данным карты высот Литература [Perlinl] Perlin, К. «Making Noise: Tutorial and History of the Noise Function» (работа доступна по адресу www.noisemachine.com). [Perlin2] Perlin, К. «Improving Noise». Computer Graphics, Vol. 35, No. 3 (работа дос- тупна по адресу http://mrl.nyu.edu/~perlin/paper445.pdf).
Глава 7 ЛАНДШАФТНАЯ СИСТЕМА ROAM В предыдущей главе мы рассмотрели базовую конструкцию ландшафта на основе данных карты высот и ряд основных способов генерации данных высотной карты. В этой главе мы обратимся к более совершенным методам управления геометрическими дан- ными о ландшафте. До сих пор мы работали с ландшафтной геометрией, имевшей равно- мерное разрешение. В известном смысле весь ландшафт мы отображали на самом высо- ком уровне его детального представления (LOD). На ограниченных по своим возможно- стям видеокартах этот подход быстро теряет свою практичность, как только ландшафт становится больше, а наши методы его рендеринга усложняются. В этой главе мы введем методы хранения ландшафта в виде модульных единиц и управления LOD для уменьше- ния, насколько возможно, нагрузки, которую вызывает рендеринг. С Нами будет представлено несколько известных методов управления гео- метрией. Каждый из них имеет свои сильные и слабые стороны, что ставит программиста перед нелегким выбором: какой же метод использовать в приложении? Для демонстрации каждого метода на компакт-диске приводятся небольшие примеры программ, однако, представленный в книге итоговый вариант движка ландшафтного син- теза будет использовать один-единственный метод. Впрочем, за основу всех методов мы берем объекты базовых классов cTerrain и cTerrainSection, созданных в преды- дущей главе. Совместно используя общий набор функций организации интерфейса, мы во многом способствуем взаимозаменяемости разных ландшафтных методов. Все представленные приемы преследуют одну главную цель - больше треугольников на тех участках ландшафта, где это необходимо, и меньше-там, где острой необходи- мости в этом нет. По сути, каждый метод строит больше треугольников вблизи камеры, снижая их количество на более отдаленных участках. Предположение, лежащее в основе этого, базируется на том факте, что при рендеринге удаленные зоны реально представ- лены меньшим количеством пикселов на экране, а значит, число треугольников может.
ЛАНДШАФТНАЯ СИСТЕМА ROAM __ 155 быть снижено без особого видимого эффекта. Конечно, мы будем учитывать и сложность ландшафта, гарантируя, что зоны с малой вариацией высоты (плоские участки суши и т. д.) будут представлены наименьшим количеством треугольников, на более же слож- ных участках все треугольники останутся на местах независимо от удаленности камеры. Меши с оптимальной подгонкой в реальном времени Первым мы изучим алгоритм меша с оптимальной подгонкой в реальном времени (Real-Time Optimal Adapting Mesh), больше известный как ROAM. Впервые он был пред- ставлен в работе Марка Дюшено (Mark Duchaineau) и др. [Duchaineau] как алгоритм, соз- данный для упрощения визуализации больших участков ландшафта. Хотя метод, описан- ный в той работе, носил революционный характер, авторы многих игр посчитали нужным доработать эту идею ради ее совместимости с аппаратурой. В нашем обсуждении этого метода мы раскроем базовые принципы ROAM, однако наша реализация будет слегка отличаться от описанной в оригинальной работе. ROAM основан на одном из свойств равнобедренного прямоугольного треугольника. Как видно из рис. 7.1, такой треугольник можно поделить на два равных равнобедренных прямоугольных треугольника, разбив исходную фигуру по линии, соединяющей угол при вершине между равными сторонами с центром основания треугольника. Процесс деления можно продолжать бесконечно, всякий раз удваивая число треугольников. Коль скоро каждый из треугольников является потенциальным родителем двух меньших фигур, то формируется иерархия типа «бинарное дерево». ROAM - это, в основе своей, метод управления тем, какие треугольники делятся пополам, а какие сливаются и совпадают с родителем. Это позволяет увеличивать и уменьшать степень детализации ландшафта на уровне треугольников. Рис. 7.1. Прямоугольный треугольник может быть разбит на два меньших треугольника, тоже являющихся прямоугольными
156 Глава? Каждый раз при разбиении треугольника на середине его основания строится новая вершина. Ее положение можно найти интерполяцией как среднее между конечными точками основания. Однако свое значение по оси z новая вершина наследует от меша высот, так поднимая или опуская ее, чтобы добиться согласия с базовыми ландшафтными данными. Меру перемещения новой вершины по оси z мы назовем смещением (displace- ment) и сохраним по абсолютному значению величины (см. рис. 7.2). Рис. 7.2. Смещение в треугольнике есть разность между интерполированной позицией вершины и реальной, найденной по карте высот Само по себе смещение не слишком информативно; оно лишь указывает, насколько отдельная вершина меша (vertex) сместится при делении треугольника. Чтобы опреде- лить, следует ли делить треугольник, нам нужно знать, точно он описывает данные о высоте накрытой им зоны или же нет. То, что нам надо, - не просто дельта по высоте отдельной вершины. К счастью, при спуске по двоичному дереву треугольников фигуры становятся все меньше и меньше, при этом каждый набор вершин накрывает собой все меньшие по размеру зоны карты высот. Это значит, что отношение площади треугольни- ков к площади зоны на карте высот с продвижением вниз по дереву приближается к 1:1. Стало быть, проверив значения всех смещений потомков данного треугольника, сможем получить более точный ответ на вопрос, действительно ли эта фигура в деталях описывает данные той карты высот, которую она представляет. Для этого проделаем рекурсивный спуск по дочерним узлам и найдем самую боль- шую невязку между вершинами треугольников и реальной высотной отметкой. На уровне треугольников эта невязка уже имеется как смещение, поэтому наша задача - найти наи- большее и занести его в родительский треугольник. Такой максимум среди смещений дочерних узлов мы назовем метрикой ошибки (error metric) конкретного треугольника. Это значение послужит мерой того, насколько близко треугольник повторяет данные о высоте. Метрика ошибки, равная нулю, описывает идеальную фигуру, точно отра- жающую базовую высотную информацию. Более высокие значения метрик ошибки ука- зывают на то, что треугольник плохо представляет зону, которую он накрывает. Метрику ошибки, заранее рассчитанную для каждого треугольника дерева, можно затем сравнить с по пученным в ходе работы значением, основанным на удаленности камеры, после чего решить, следует ли выполнять деление треугольника. Фактически метрика ошибки - это максимальное смещение среди всех дочерних узлов, поэтому мы
ЛАНДШАФТНАЯ СИСТЕМА ROAM 157 можем уверенно разбивать треугольник по рекурсии до тех пор, пока не найдем фигуру, которая порождает ошибку. Автором этого метода расчета метрик ошибки является Сьюмас Мак-Нелли (Seumas McNally), с большим успехом использовавший свой метод в игре TreadMarks™ компании Longbow Digital Arts®. Идеи Мак-Нелли о применении двоичных деревьев из треугольников и методики ROAM стали залогом того, что на втором ежегодном фестивале Independent Games Festival игре TreadMarks были присуждены три награды, и в их числе - высшая. Новшества, предложенные Мак-Нелли, изложены Брайа- ном Тернером (Brian Turner) в написанной им для Gamasutra статье «Real-Time Dynamic Level of Detail Terrain Rendering with ROAM» [Turner]. Значение, которое получено во время работы и служит для проверки метрики ошибки каждого треугольника, зависит от характера приложения. Наша цель состоит в том, чтобы задать значение, представляющее собой максимум допустимых метрик ошибок. Фигуры с метрикой ошибки меньше данного значения остаются, с большей - подвергаются разбие- нию. Каждый раз при делении треугольника проверка повторяется по отношению к обоим его потомкам. Чтобы сделать проверку видозависимой, надо увеличить допустимое значение максимума при удалении фигуры от камеры. Этим гарантируется, что треугольники, лежа- щие ближе к камере, делятся на части более тщательно, чем те, что находятся в отдалении. Решение о разбиении На практике для управления делением треугольников воспользуемся тремя величи- ями: расстояние, масштаб и предел. Расстояние - обычная мера пространства между конкретной точкой и наблюдателем. Вкупе с метрикой ошибки на ее основе можно получить отношение ошибки и расстояния. Если значение этого отношения выйдет за кон- <ретный предел, то разбиение придется произвести. Этот дополнительный коэффициент озволит преувеличивать или преуменьшать метрику ошибки для более тонкого контроля ад выбранным уровнем LOD. Более высокие значения масштабного коэффициента повы- зают отношение ошибки и расстояния и делают разбиения более вероятными. Дробные начения масштаба, стремящиеся к нулю, оказывают обратное действие. Окончательный езультат приведен в уравнении 7.1. ES ^=->L (7Л) Split - разбиение, Е - метрика ошибки, S - масштаб ошибки, D - расстояние до наблюдателя, L - предел отношения.
158 Глава? Как видно из данного уравнения, мы должны разбить треугольник в том случае, если отношение умноженной на масштабный коэффициент ошибки и расстояния превысит ука- занный нами предел. Это простое уравнение дает возможность учесть значение расстояния до наблюдателя и топографию ландшафта в ходе анализа необходимости разбиения. Зоны ландшафта, лежащие ближе к камере, будут поделены с большей вероятностью, так же как ,и зоны с высокими значениями ошибки, например скалистые или горные регионы. Зоны, которые требуют лишь небольшого количества треугольников, имеют крайне низкие значе- ния ошибок, что помогает нам гарантировать отсутствие в них лишних фигур. Выбрать в качестве приоритета расстояние или неровность поверхности помогают значения масштабных коэффициентов. Больший коэффициент в уравнении склоняет чашу весов в пользу рельефа ландшафта, делая разбиение неровных участков поверхности более вероятным, чем расположенных неподалеку от камеры. Меньший коэффициент ока- зывает противоположное действие, делая расстояние куда более важным при вычисле- ниях, чем неровность ландшафта. Для отыскания масштабного коэффициента и предель- ного значения отношения, лучше других подходящего в случае с конкретным ландшаф- том, необходима постановка экспериментов. При разбиении фигур мы сделаем еще один шаг, призванный сохранить целостность меша, состоящего из треугольников. Заметим, что если один из треугольников на рис. 7.3 будет разбит, а новая вершина сместится, то на поверхности образуется щель. Она носит название Т-стыка (T-junction) и представляет собой проблему, которую должны учитывать все методы, представляющие ландшафт как мозаику. Чтобы устранить щель, мы должны гарантировать, что при делении одного треугольника другой, имеющий с ним общую гипотенузу, тоже должен подвергнуться разбиению. Рис. 7.3. Деление одного из треугольников без изменения другого, смежного с ним по стброне разбиения, может привести к образованию щели Это породит в меше «эффект домино». Коль скоро треугольник разбит, он может вызвать деление своих соседей. Чтобы эффективно использовать бинарное дерево тре- угольников, мы должны дать гарантии того, что каждая из выполняемых нами операций
ЛАНДШАФТНАЯ СИСТЕМА ROAM 159 деления или слияния обновляет соседние треугольники. Всякий треугольник должен не только знать о дочерних узлах на нижележащем уровне, но и о трех соседях, смежных с ним по его сторонам. Связи треугольников между собой показаны на рис. 7.4. Рис. 7.4. Каждый узел бинарного дерева треугольников содержит ссылки на дочерние узлы и три соседние фигуры Возможным исходом при разбиении треугольника является один из двух случаев. Если сосед по основанию (как показано на рис. 7.4) имеет общую с разбитым треугольником гипотенузу, то для предотвращения образования щели разбит должен быть он- один. Если сосед по основанию не имеет общей с разбитым треугольником гипотенузы, он должен рекурсивно подвергаться делению до тех пор, пока не образуется треугольник, по гипоте- нузе смежный с тем, который мы разбиваем. Этот рекурсивный процесс показан на рис. 7.5. Рис. 7.5. Шаги, необходимые для принудительного деления смежного треугольника Слева пунктиром показано желаемое разбиение. Справа приведено общее число операций принудительного деления
160 Глава? Реализация ROAM В главе 6 «Основы ландшафтной геометрии» мы ввели классы cTerrain и cTerrainSec- tion для управления базовым набором элементов ландшафтной геометрии в статике. В нашей реализации ROAM мы должны породить от этих базовых объектов свои новые классы. Это тема, к которой мы будем периодически возвращаться, описывая каждый метод управления геометрией наших ландшафтов. Хотя их реализация и будет меняться, сам интерфейс между всеми методами останется практически неизменным. Так же как мы делили исходный ландшафт на сетку из объектов cTerrainSection, поделим на аналогичные области и наш ROAM-ландшафт. Хотя метод ROAM во многом основан на бинарном дереве треугольников, на деле мы увидим, что построение единст- венного дерева для всего ландшафта является неудобным. Создание подобного дерева с одним корневым треугольником потребует множества ненужных разбиений лишь для того, чтобы достичь минимального уровня детализации ландшафта. Вместо этого мы будем по-прежнему делить ландшафт на образующие сетку участки, каждый из которых станет содержать пару бинарных деревьев, состоящих из треугольников. Эти квадраты на сетке будут самым низкоразрешающим из вариантов ландшафта, так как подобная реа- лизация с самым малым числом деталей должна лишь отобразить на каждом участке сетки те треугольники, что являются корнями деревьев. Для организации ROAM-ландшафта мы породили два класса: cRoamTerrain и cRoamTerrainSection. Они так же связаны между собой, как классы в паре CTerrain и cTerrainSection: cRoamTerrain является центральным хранилищем всех данных ландшафта, объекты cRoamTerrainSection представляют расположенные в виде сетки дискретные участки последнего. Хотя каждый класс наследует большую часть своих функций от исходных ландшафтных базовых классов, в них должны быть внесены отдель- ные дополнения, призванные справляться с динамической природой ROAM-ландшафтов. Работая с каждым кадром, мы будем делить ландшафт, пользуясь проверкой на основе метрик ошибок, описанной уравнением 7.1, и строить перечень лучше всех представ- ляющих ландшафт треугольников. Для упрощения этого динамического перечня тре- угольников нам понадобится внести в класс cRoamTerrain ряд возможностей по управле- нию памятью. Для начала мы опишем структуру данных, необходимую каждому тре- угольнику, после чего изучим способы того, как эффективно управлять выделением и освобождением памяти под такими структурами. Как было показано на рис. 7.4, каждый из треугольников в системе ROAM содержит ссылки на пять соседних и дочерних фигур. Представим их в виде несложной структуры cTriTreeNode, приведенной в листинге 7.1. Эта структура описывает отдельный узел бинарного дерева треугольников и пять ссылок на соседние узлы и потомков. cRoamTer- rain в своем составе имеет фиксированный пул подобных структур, давая объектам cRoamTerrainSection возможность запрашивать структуры узлов, а не выполнять какие
ЛАНДШАФТНАЯ СИСТЕМА ROAM 161 бы то ни было действия по выделению памяти в ходе работы программы. Это пул с фик- сированным объемом, поэтому не все запросы новых узлов будут им удовлетворяться. Если пуд пуст, то все дальнейшие запросы узлов становятся безрезультатны, а разбиение треугольников приходится прекращать. Помимо прочего, листинг 7.1 содержит функцию- член requestTriNode класса cRoamTerrain, предназначенную для управления запро- сами структур cTriTreeNode. ЛИСТИНГ 7.1. Управление запросами cTriTreeNode в классе cRoamTerrain /* cTriTreeNode Это отдельный узел бинарного дерева треугольников в системе ROAM. Пул этих структур хранится в классе cRoamTerrain, выделяющем под них память. */ struct CTriTreeNode { cTriTreeNode *baseNeighbor; cTriTreeNode *leftNeighbor; cTriTreeNode *rightNeighbor; cTriTreeNode *leftChild; cTriTreeNode *rightChild; 1; // эта функция обрабатывает запросы клиентов // на организацию объектов cTriTreeNode в нашем // локальном пуле. Для сохранения очередного // доступного индекса пула используется // m_nextTriNode. По достижении этим индексом // конца пула все его узлы будут заняты. cTriTreeNode* cRoamTerrain::requestTriNode() { cTriTreeNode* pNode = 0; if (m_nextTriNode < k_maxTriTreeNodes) { // извлечем узел и пул // и очистим от старых данных pNode = &m_pTriangleNodePool[m_nextTriNode]; memset(pNode, 0 ,sizeof(cTriTreeNode)); ++m_nextTriNode; 1
162 Глава 7 // данное значение может быть нулевым. Эту возможность // должны уметь обрабатывать вызывающие подпрограммы return pNode; } В листинге 7.1 вы могли обнаружить отсутствие механизма освобождения запрошен- ных в пуле объектов cTriTreeNode. Это делается умышленно. В каждом кадре мы строим новое бинарное дерево треугольников с нуля, так что отдельные узлы никогда не передаются обратно в пул. Вместо этого в начале каждого кадра происходит сброс всего пула, для чего счетчик m_nextTriNode попросту обнуляется. Кроме того, в каждом cRoamTerrainSection мы очищаем связи корневых треугольников с их потомками, тем самым полностью разрушая дерево, построенное для предыдущего кадра. Применение постоянного по объему пула структур cTriTreeNode и сброс в каждом кадре делают совершенно ненужным какое-либо распределение памяти в ходе работы сис- темы ROAM. Это решение куда эффективнее, чем динамическое выделение памяти, однако оно обладает одним существенным недостатком. Пул имеет ограниченную емкость, что заставляет нас остановить деление треугольников, как только источник иссякнет. Когда же это произойдет, мы должны вывести все треугольники, которые уже были разбиты, и перей- ти к очередному кадру. Каждый cRoamTerrainSection содержит две базовые структуры cTriTreeNode в качестве членов класса, поэтому на каждом участке ландшафта мы обяза- тельно имеем, как минимум, два треугольника. Это предохраняет от появления «дыр» в ландшафте, однако недостаток числа разбиваемых треугольников может стать причиной того, что его участки будут представлены с меньшим уровнем детализации, чем мы хотим. Противопоставим этому выбор приоритетов при замощении объектов cRoamTerrain- Section . Зная, что в какой-то момент у нас могут закончиться структуры cTriTreeNode, мы должны в первую очередь обеспечить разбиение треугольников на ближайших к камере участках cRoamTerrainSection. Если структур cTriTreeNode окажется недостаточно, объекты cRoamTerrainSection, которые будут вынужденно представлены с меньшим уровнем LOD, скорее всего окажутся в конце очереди, дальше всех остальных. Для реализации этого списка с приоритетами объекты cRoamTerrainSection, нахо- дящиеся в пределах поля зрения камеры, рассчитывают свое удаление от нее и отсылают себя в очередь на замощение в родительском классе cRoamTerrain. В самом этом классе имеющая фиксированный размер очередь указателей на объекты cRoamTerrainSection служит для организации списка участков, требующих мозаичного представления в очередном кадре. Когда в очередь помещены все участки ландшафта, алгоритм быстрой сортировки переставляет содержимое списка по близости к наблюдателю. Этот перестро- енный список используется затем для планирования замощения всех объектов cRoamTer- rainSection, что позволяет ближайшим к камере участкам первыми получить в пуле узлы cTriTreeNode.
ЛАНДШАФТНАЯ СИСТЕМА ROAM 163 Сложно? Да. Однако мы добиваемся своего без выделения памяти в ходе работы сис- темы. Последнее оставшееся предупреждение касается того, что очередь на замощение имеет фиксированный размер, а потому должна быть достаточно велика, чтобы вместить все объекты типа cRoamTerrainSection, требующие мозаичного представления. Выход за пределы очереди на замощение вернет нас к исходной проблеме досрочного останова процесса, ведущего к плохому подразбиению треугольников в отдельных местах. Хорошая же новость состоит в том, что элементы очереди на замощение - это обычные указатели, поэтому мы можем позволить себе заранее создать эту очередь так, чтобы она могла вместить в себя столько указателей на cRoamTerrainSection, сколько, как мы думаем, нам когда-нибудь встретится. О выходе за пределы очереди на замощение нас предупредят внедренные в код утверждения (assertions). Если это произойдет, нам придется сделать очередь более длинной. Ряд функций-членов класса cRoamTerrain, отвечающих за орга- низацию и обработку очереди на замощение с приоритетами, приведены в листинге 7.2. Следует заметить, что исходная реализация ROAM, предложенная Дюшено и др. [Duchaineau], также вводила в оборот метод хранения операций разбиения и слияния как пары очередей с приоритетами. Эти очереди Служили для назначения приоритетов опера- циям разбиения и слияния по всему мешу, причем для запуска каждого процесса исполь- зовалась межкадровая когерентность. В нашей реализации ROAM мы пользуемся более простым подходом и рекурсивно разбиваем треугольники сверху вниз, организуя новую сетку для каждого кадра. Будучи не столь безупречной, наша «грубая сила» как способ введения алгоритма ROAM и двоичного дерева треутольников проще в программирова- нии и управлении. Успешная реализация ROAM-алгоритма в таких популярных играх, как TreadMarks компании Longbow Digital Arts, показывает, что очереди с приоритетами не столь необходимы для поддержания приемлемой для игры частоты смены кадров. Так что введение подобных очередей, описанных в оригинальной работе, мы оставляем чита- телю в качестве упражнения. ЛИСТИНГ 7.2. Основные фрагменты функций-членов класса cRoamTerrain, предназначенных для управления очередью мозаичного представления объектов cRoamTerrainSections // // сброс осуществляется в начале работы над каждым кадром // для возвращения всех счетчиков к нулю и подготовки // к заполнению новыми элементами пула узлов-треугольников // и очереди на замощение (мозаичное представление) / / voi-i cRoamTerrain: : reset () { // сбросим внутренние счетчики m_tessellationQueueCount = 0; в*
164 Глава? m_nextTriUode=0; // сбросим каждый участок int total = m_sectorCountY*m_sectorCountX; for (int i=0; ictotal; ++i) { m_pRoamSectionArray[i].resetO; } } It // По мере извлечения участков из квадрадерева // они сами ставят себя в. очередь на замощение // bool cRoamTerrain::addToTessellationQueue( cRoamTerrainSection* pSection) { if (m_tessellationQueueCount < k_tessellationQueueSize) { m_tessellationQueue[m_tessellationQueueCount] = pSection; ++m_tessellationQueueCount; return true; } // в итоговой версии мы элегантно обработаем этот сбой, // однако в отладочном варианте, пользуясь утверждением, // выдадим предупреждение о сложившейся ситуации, чтобы // увеличить размер очереди debug_assert( О, "increase the size of the ROAM tessellation queue"); // "увеличить размер очереди на замощение в ROAM" return false; } // используемый алгоритмом быстрой сортировки // локальный сортировочный функтор typedef cRoamTerrainSection* LPRoamSection; struct sort_less { bool operator()( const cRoamTerrainSection*& a, const cRoamTerrainSection*& b)const {
ЛАНДШАФТНАЯ СИСТЕМА ROAM 16f return a->queueSortValue() < b->queueSortValue(); } ); // // Эта функция служит для сортировки очереди и дает // возможность в нужном порядке замостить каждый участок // void cRoamTerrain::processTessellationQueue() { // отсортируем список на замощение // подробности реализации // см. в "core\quick_sort.h" Quicksort(m_tessellationQueue, m_tessellationQueueCount, sort_less()) ; // построим мозаичное представление всех участков uint32 i; for (i=0; i<m_tessellationQueueCount; ++i) { // выполним деление треугольников с учетом // масштаба и предельного значения отношения m_tessellationQueue[i]->tessellate( m_vScale, m_vLimit); } // соберем в итоговый индексный буфер все // треугольники данного участка ландшафта for (i=0; i<m_tessellationQueueCount; ++i) { m_tessellationQueue[i]->buildTriangleList(); } Создание экранной геометрии в ROAM Как показывает конец листинга 7.2, последний шаг, следующий за постановкой з очередь всех участков и разбиением всех фигур, - это обратный проход по дереву и сбор треугольников с целью вывода на экран. Как и в нашей исходной реализации ландшафта из лавы 6, каждый его участок отдельно визуализирует отсортированная очередь на рендеринг. Все, что нам остается, - обойти все видимые объекты cRoamTerrainSection и построить
166 Глава? необходимые для постановки в очередь вершинные и индексные буферы. Это делает член класса cRoamTerrainSection - функция buildTriangleList. В нашей реализации использованы именно те буферные потоки вершин, которые находятся в базовом классе cTerrain. Все, чего требует наш метод мозаичного представления, - создать индексный буфер, который опишет реально отображаемые тре- угольники. Этот подход дает известный выигрыш в производительности по сравнению с созданием вершинного буфера, который при построении каждого кадра потребует пере- давать видеокарте гораздо больше данных по AGP-шине. При нашей реализации вершины остаются в памяти видеокарты как статически размещенные, а в каждом кадре меняются лишь индексы вершин треугольников. Создание этого множества индексов требует про- стой рекурсии со спуском по дереву до каждого листового узла. Как только лист обнару- жен, представленный им треугольник заносится в индексный буфер. Когда его формиро- вание завершено, участок ландшафта готов к отправке в очередь на рендеринг. Заложен- ные в класс cRoamTerrainSection рекурсивные функции создания динамического индексного буфера для вывода на экран представлены в листинге 7.3. ЛИСТИНГ 7.3. Создание динамического индексного буфера для рендеринга участка ROAM void cRoamTerrainSection::buildTriangleList() { // заблокируем динамический индексный буфер m_p!ndexList = m_pIndexBuffer->lock( nWriteLock, 0, 0) ; m_totallndices=0 ; // добавим все треугольники в roamTerrain - // корневой треугольник А recursiveBuildTriangleList( &m_rootTriangleA, 0, 16, 16*17); // добавим все треугольники в roamTerrain - // корневой треугольник В recursiveBuildTriangleList( &m_rootTriangleB, (17*17)-1, 16*17, 16); // разблокируем индексный буфер m_p!ndexBuffer->unlock() ; m_p!ndexList = 0 ; } void cRoamTerrainSection::recursiveBuildTriangleList( cTriTreeNode *tri, uintl6 iCornerA, uintl6 iCornerB, uintl6 iCornerC)
ЛАНДШАФТНАЯ СИСТЕМА ROAM 167 { // если треугольник имеет потомков, то нарисуем их if (tri->leftChild) { debug_assert( tri->rightchild, "invalid triangle node"); // "узел с треугольником некорректен" uintl6 iMidpoint = (iCornerB+iCornerC)>1; recursiveBuildTriangleList( tri->leftChild, iMidpoint, iCornetA, iCornerB); recursiveBuildTriangleList( tri->rightChild, iMidpoint, iCornerC, iCornerA); } else if (m_totallndices + 3 < m_maxlndices) { // внесем локальный треугольник в перечень индексов m_p!ndexList[m_totallndices++]=iCornerC; m__pIndexList[m_totallndices++]=iCornerB; m__pIndexList[m_totallndices++]=iCornerA; } } c Чтобы завершить рассмотрение ROAM, мы приведем еще один листинг. На компакт-диске вы сможете найти демонстрационную программу, которая использует описанные здесь классы ROAM. Эта программа chapter7_demo0.exe в файле main, срр решает задачи настройки и отображения ROAM-ландшафта. В этом файле выполнена небольшая дополнительная работа по обнаружению всех видимых участков дандшафта, запросу их мозаичного представления и обработке каждого элемента очереди. Все действия производятся в перечисленных в этой главе функциях-членах, тогда как файл main.срр отвечает за инициацию названных процедур. В листинге 7.4 показаны простые шаги, предпринятые программой chapter7_demo0.exe для замоще- ния и рендеринга ROAM-ландшафта. ЛИСТИНГ 7.4. Управление мозаичным представлением и рендерингом ROAM-ландшафта. Этот код - фрагмент файла main. срр в составе программы chapter7_demo0 . ехе // найдем все видимые объекты, включая // участки ROAM-ландшафта cSceneObject* pFirstMember = quadTree().buildSearchResults{ activecamera () ->searchRect () ) ,-
168 Глава? cSceneObject* pRenderList = pFirstMember; // сбросим ROAM-ландшафт m_terrainSystem.reset(); // подготовим все объекты к отображению // поставим участки ROAM-ландшафта в // очередь на мозаичное представление while(pFirstMember) { pFirstMember->prepareForRender(); pFirstMember = pFirstMember->nextSearchLink(); } // замостим все помещенные в очередь участки ландшафта m_terrainSystem.processTessellationQueue(); // произведем рендеринг всех объектрв, // включая вновь замощенные участки ROAM. // (поместим их в очередь на рендеринг) pFirstMember = pRenderList; while(pFirstMember) { pFirstMember->rpnder(); pFirstMember = pFirstMember->nextSearchLink(); } Литература [Duchaineau] Duchaineau, M., M. Wolinski, D. Sigeti, M. Miller, C. Aldrich, and M. Mineev-Weinstein. «ROAMing Terrain: Real-Time Optimally Adapting Meshes» (работа дос- тупна по адресу www.llnl.gov/graphics/ROAM). [Turner] Turner, В. «Real-Time Dynamic Level of Detail Terrain Rendering with ROAM» (работа доступна по адресу www.gamasutra.eom/features/20000403/tumer_01.htm).
Глава 8 МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ Одним из видимых недостатков метода ROAM является его зависимость от дина- мических индексных буферов, которыми он пользуется при выводе на экран. Точность, обусловленная выбором уровня детализации (LOD) каждого треугольника, обеспечивает почти идеальную геометрию, однако благодаря ей набор геометрических форм при каждом положении камеры может быть уникален. Загрузка созданного индексного буфера в видеокарту наносит серьезный удар по производительности в режиме реального вре- мени, и эту проблему только усиливает наше перестроение множества вершин в каждом кадре. Кроме того, традиционная рекурсия при обработке бинарного дерева треугольни- ков ведет к созданию отдельных фигур, которые нужно отобразить, Это решение гораздо менее эффективно, чем упакованные в слои и веера группы из треугольников, которые позволят аппаратуре использовать когерентность кеш-памяти. Хотя построить такие списки треугольников по выходным данным ROAM - вполне возможное дело, эта задача обескураживает тех программистов, которые столкнулись с ней впервые. Вместо того чтобы решать такую задачу, мы обратим свой взгляд на агрегатные методы управления ландшафтной геометрией. Под ними мы понимаем приемы, под- бирающие уровни LOD для групп треугольников, а не отдельных фигур. Эти совокупные группы традиционно описывают как квадраты на регулярной сетке, которая покрывает ландшафт. Выбор уровня LOD всегда базируется на разрешении сетки, а не свойствах кон- кретного треугольника, что позволяет заранее строить множество геометрических форм для каждого квадрата на сетке при разных уровнях LOD. Достоинством таких приемов является то, что при работе требуется производить меньший объем реальных расчетов уровней LOD, а предварительно созданные геометрические наборы могут быть построе- ны с учетом аппаратного согласования слоев и вееров. В этой главе мы рассмотрим два метода работы с ландшафтом в приращениях сетки (grid increments). Первый метод именуется Chunked Terrain (Блочный ландшафт), и его автором является Тэтчер Ульрих (Thatcher Ulrich) [Ulrich] из Oddworld Inhabitants®. Для увеличения степени детализации этот метод использует как структуру ячеек сетки дерево, во многом
170 Глава 8 напоминающее квадрадеревья. Второй метод, созданный автором [Snook], использует единую сетку ячеек, каждая из которых может отображаться с заранее рассчитанным LOD. Оба, метода зависят от дополнительных геометрических построений между ячейками с разньтмуровнем детализации, призванных скрыть любые щели, которые могут возникнуть. Блочный ландшафт Идея разбить ландшафт на квадратные блоки по регулярным линиям сетки- прекрасный ход, упрощающий бинарное дерево треугольников из ROAM. Там, где ROAM пользовался метриками ошибок для рекурсивного спуска по бинарному дереву на искомую глубину, Chunked Terrain использует подобную метрику для перехода на нужную глубину квадрадерева. Отличие состоит в том, что результат, найденный в узле квадрадерева методом Chunked Terrain, содержит маленький меш, заранее собранный для достижения пиковой эффективности процедуры рендеринга. Хотя узлы квадрадерева и не позволяют настраивать ландшафтную геометрию на уровне треугольников, эту небольшую проблему точности с лихвой перевешивает тот выигрыш, который мы получим от эффективной работы с аппаратурой. На рис. 8.1 представлено описанное автором метода дерево Chunked Terrain. На его верхнем уровне создан один квадрат, образованный двумя треугольниками. Второй уро- вень дерева содержит четыре члена, каждый из которых задает более высокий уровень детализации, чем его прямой предок. Эта конструкция повторяется до тех пор, пока разре- шение лежащей в основе ландшафта карты высот не будет полностью представлено тре- угольниками. Как и в случае с ROAM, каждому из уровней дерева приписана метрика ошибки, основанная на невязке между полученными интерполяцией вершинами и истин- ными высотными данными о ландшафте. Рис. 8.1. Два уровня LOD, заданных квадрадеревом Chunked Terrain Процесс визуализации тоже идентичен нашей реализации бинарного дерева треуголь- ников из ROAM. Пользуясь отношением ошибки и расстояния (см. уравнение 7.1) в каждом узле, мы рассчитаем метрику ошибки узла с учетом видозависимого предела допустимого
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 171 отклонения. Если узел окажется в допустимых пределах, отобразим его содержимое и дви- немся дальше. Если это не так, то перейдем на следующий уровень дерева и проверим каж- дого из четырех потомков узла. Будем повторять этот процесс до тех пор, пока не найдем все необходимые нам узлы дерева. Если две ячейки на сетке отображаются с разным уровнем мозаичного представления, то, как и в методе ROAM, перед нами встает проблема образования трещин в ландшафте. Вместо принудительного деления соседей на соответствующий набор вершин Ульрих предлагает новый подход, использующий полигональную кромку вокруг каждой отобра- жаемой ячейки на сетке. Эти кромки - не что иное, как вертикальные треугольники, раз- мещенные вдоль краев сетки для маскировки щелей между ячейками. Пример геометрии таких кромок показан на рис. 8.2. Рис..8.2. Геометрия кромок по краям четырех ячеек на сетке. Вертикальные треугольники каждой кромки обозначены серым По рис. 8.2 видно, что вертикальные многоугольники кромок способны создавать в сетке разрывы, порождая вертикальные пики на гладкой поверхности. При очень грубом замощении пользователь заметит эти пики, которые могут его отвлечь. Однако автору метода удалось показать, что с весьма низкой видозависимой допустимой ошибкой эти пики можно минимизировать часто до высоты пиксела или, лучше, делая их практически неотличимыми от окружающих многоугольников. Это значит, что такие параметры, как масштаб и предельное отношение, которыми мы пользуемся для управления выбором LOD, должны быть тонко настроены для предотвращения образования вертикальных мно- гоугольников видимого пользователям размера.
172 Глава 8 Управление блоками геометрии Блочный ландшафт хранится как множество классов, порожденных от cTerrain и cTerrainSection. Как и в нашей реализации ROAM в предыдущей главе, мы породим эти классы с целью организации аналогичного интерфейса для визуализации ландшафта и управления им. Двумя нашими новыми классами станут cChunkTerrain и cChunkTer- rainSection. Как всегда, они представляют общий родительский объект ландшафта и отдельные участки выровненной по сетке ландшафтной структуры, соответственно. Чтобы упростить применение участков-блоков и дополнительной кромочной гео- метрии, заметим, что системе для работы с блочным ландшафтом необходим ряд допол- нительных геометрических построений. Вертикальным кромкам нужны свои собствен- ные буферы индексов для задания треугольников кромок и дополнительные вершины для описания нижней границы последних. Как и во всех других классах ландшафтов и их участков, воспользуемся тем фактом, что большинство дополнительных построений могут находиться в совместном использовании всех участков ландшафта. Для начала мы должны создать кромочные вершины. Низ кромок содержит те же данные о вершинах, что и их верх, за исключением значения высоты по оси z, смещенного на некую величину. Все координаты текстур и нормали к поверхностям останутся без изменений. Для создания кромок мы попросту продублируем вершины из сетки, скопиро- вав все данные и сместив значение высоты каждой вершины. Вместо сохранения новых данных в отдельный вершинный буфер мы лишь удвоим размер исходного буфера и при- пишем новые данные в его конец. Этим мы преобразуем свой индекс вершин в два раздела и назовем их страницами (pages). Первая половина вершинного буфера (страница 0) есть геометрия реальной поверхности участка ландшафта, вторая же половина (страница 1) содержит тождественную ей геометрию, смещенную на некое расстояние по вертикали для получения кромки. Создание дубликата набора вершин сначала может показаться неэкономным, однако помните, что кромка может оказаться необходимой как вокруг внешней границы cChunk- TerrainSection, так и внутри участка. Делая обход квадрадерева, мы рекурсивно будем делить его участки на квадраты меньших размеров, поэтому кромка может понадобиться и между внутренними границами зон разной степени детализации. Запись же дополни- тельных вершин на вторую страницу исходного вершинного буфера делает новую гео- метрию прозрачной для шейдеров. Применив буферы индексов для контроля за тем, какие треугольники реально отображаются, мы можем использовать одни и те же вершинные и пиксельные шейдеры для рисования как элементов мозаики, так и их кромок. В листинге 8.1 показаны те дополнительные действия по настройке, которые необходимы для создания вершинных и индексных буферов. Мы и сейчас применяем тот же двухпо- точный подход, который использовали при построении стандартного и ROAM-ландшафта, поэтому вершины в составе cChunkTerrain, которые совместно используются всеми участ-
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 173 ками, и относящиеся к отдельным участкам вершины в cChunkTerrainSection должны быть продублированы. Кроме того, родительский класс cChunkTerrain создает множество индексных буферов, определяющих положение кромок. То, что мы по-прежнему осуществ- ляем рендеринг ландшафта с делением на участки, дает возможность создать единое мно- жество индексных буферов кромок всего ландшафта, так же как в главе 6 «Основы ланд- шафтной геометрии» мы поступили с индексными буферами стандартного cTerrain. ЛИСТИНГ 8.1. Настройка вершинного и индексного буферов в классах элементов мозаики блочных ландшафтов // эти константы используются // в коде, который приведен ниже. // Их можно найти в тексте // описания классов cChunkTerrain // и cChunkTerrainSection enum e_index_type { k_chunk = 0, k_skirt, k_total!ndexTypes }; enum e_constants { k_minTessellationShift = 2, k_maxDetailLevels = 4, k_topLod = 3, k_cellShift = 2, }; #define SKIRT—HEIGHT 50.Of bool cChunkTerrainSection::buildVertexBuffer() { bool result = true; // // Построим буфер вершин и определим // минимальный/максимальный размер участка // cString tempName; tempName.format( "terrain—section_%i_%i", m_sectorX, m_sectorY); m_pSectorVerts =
174 Глава 8 TheGameHost.displayManager(). vertexBufferPool().createResource(tempName); m_worldRect.zO = MAX_REAL32; m_worldRect.zl = MIN_REAL32; // // работая с блочным ландшафтом, мы создадим буфер // вершин, по числу элементов вдвое превосходящий // обычный. Его доли мы назовем "страницами". Первая // страница - это реальный буфер вершин, вторая - // смещенный вариант каждой вершины, используемый // при визуализации кромок // uint32 pageSize = m_xVerts*m_yVerts; uint32 buffersize = pageSize<<l; if (m_pSectorVerts) { // прочитаем высоту и нормаль каждой вершины cTerrain::sSectorVertex* pVerts = new cTerrain::sSectorVertex[buffersize]; for (uintl6 у = 0; y<m_yVerts; ++y) { for (uintl6 x = 0; x<m_xVerts; ++x) { float height = m_pTerrainSystem->readWorldHeight( m_heightMapX+x, m_heightMapY+y); cVector3 normal = m_pTerrainSystem->readWorldNormal( m_heightMapX+x, m_heightMapY+y); int vertlndex = (y*m_xVerts)+x; pVerts[vertlndex].height = height; pVerts[vertlndex].normal = normal; height -= SKIRT_HEIGHT; pVerts[vertlndex+pageSize].height = height; pVerts[vertlndex+pageSize].normal = normal;
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 175 m_worldRect.zO = minimum(m_worldRect. zO, height); m_worldRect.zl = maximum(m_worldRect.zl, height); } } result = result && m_pSectorVerts->create( buffersize, sizeof(cTerrain::sSectorVertex), FLAG(cVertexBuffer::nRamBackupBit), pVerts); safe_delete_array(pVerts) ; } else { result = false; } return result; } bool cChunkTerrain::buildVertexBuffer() { cString tempName; tempName.format("terrain_system_%i", this); // создадим буфер вершин, которым участки // ландшафта будут пользоваться совместно m_pVertexGrid = DisplayManager.vertexBufferPool(). createResource(tempName); cVector2 cellsize( m_sectorSize.x/m_sectorUnits , m_sectorSize.y/m_sectorUnits); int pageSize = m_sectorVerts*m_sectorVerts; int buffersize = pageSize<<l; cVector2 vert(0.Of,0.Of); sLocalVertex* pVerts = new sLocalVertex[buffersize]; // заполним поток вершин позициями по х,у и // координатами по uv. Все остальные данные // (высоты и нормали к поверхностям) хранятся в
176 Глава 8 // вершинных буферах каждого участка ландшафта for (int у=0; y<m_sectorVerts; ++у) { vert.set(0.Of, y*cellsize.у); for (int x=0; x<m_sectorVerts; ++x) { int index = (y*m_sectorVerts)+x; cVector2 UV( (float)x/(float)(m_sectorVerts-l), (float)y/(float)(m_sectorVerts-l)); pVerts[index].xyPosition = vert; pVerts[index].localUV = UV; // скопируем эти данные // на вторую страницу pVerts[index+pageSize].xyPosition = vert; pVerts[index+pageSize].localUV = UV; vert.x += cellsize.x; } } // теперь, когда мы создали данные, организуем // один из наших объектов-ресурсов типа “буфер // вершин" и занесем в него эти данные bool result = m_pVertexGrid->create( buffersize, sizeof(sLocalVertex) , 0, pVerts); safe_delete_array(pVerts); return result; } bool cChunkTerrain::buildlndexBuffer() { bool result = true; // индексный буфер по умолчанию в блочном // ландшафте не требуется. Вместо него мы // построим единый набор индексных буферов // для всех уровней детализации и каждой // возможной границы кромки int stride = (l<<m_sectorShift)+1; int stepSize = stride>k_minTessellationShift;
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 177 int vertCount = (l<<k_minTessellationShift)+1; m_detailLevels = 0; while (stepSize && result ScSc m_detailLevels<k_maxDetailLevels) { cString tempName; tempName.format( "chunk_index_buffer_%i", m_detailLevels); m_indexBufferList[k_chunk][m_detailLevels] = DisplayManager.indexBufferPool(). createResource(tempName); result = result && m_indexBuf ferList [k_chunk][m_detailLevels]->createSingleStripGrid( vertCount, // длина сетки vertCount, // высота сетки stepSize, // число вершин в ячейке (гориз.) stepSize, // число вершин в ячейке (верт.) stride, // число вершин в вершинном буфере (гориз.) 0) ; stepSize>=l,* ++m_detai1Levels; } // построим индексный буфер для каждой кромки. // Будем считать, что на каждом уровне детализации // мозаичный элемент есть квадрат с углами // А,В,С и D, такой, как на рисунке внизу. /* D----------С Л I I I I I п I | О I I 3 I I А----------В У поз X -> */ // следующий код строит по одному
178 Глава 8 // слою для каждой кромки на // сторонах АВ, ВС, CD и DA int sideLength = (l<<m_sectorShift)+1; int pageSize = sideLength*sideLength; for ( int iLevel=0; result && iLevel<m_detailLevels; ++iLevel) { cString tempName; tempName.format( "chunk_skirt_index_buffer_%i", iLevel); m_indexBufferList[k_skirt][iLevel] = DisplayManager.indexBufferPool(). createResource(tempName); int skirtSide = (l<<k_minTessellationShift)+1 ; int indexCount=skirtSide<<3; uintl6* indexList = new uintl6[indexCount]; uintl6* plndex = indexList; int vStep; int vlndex; int count; int horzStep = (sideLength>iLevel)>k_minTessellationShift; int vertstep = sideLength*horzStep; // сторона AB v!ndex=0; vStep = vertstep; for(count=0; count<skirtSide;++count) { *(plndex++)=vlndex; *(plndex++)=v!ndex+pageSize; vlndex +=vStep; } // сторона ВС vlndex -= vStep; vStep = horzStep; for(count=0; count<skirtSide;++count) {
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 179 *(plndex++)=vlndex; *(plndex++)=vlndex+pageSize; vlndex +=vStep; } // сторона CD vlndex -= vStep; vStep = -vertStep; for(count=0; count<skirtSide;++count) { *(plndex++)=vlndex; *(plndex++)=v!ndex+pageSize; vlndex +=vStep; } // сторона DA vlndex -= vStep; vStep = -horzStep; for(count=0; count<skirtSide;++count) { *(plndex++)=vlndex; *(plndex++)=v!ndex+pageSize; vlndex +=vStep; } result - result && m_indexBufferList[k_skirt][iLevel]->create( D3DPT_TRIANGLESTRIP , indexCount, 0, indexList); safe_delete_array(indexList); } return result; } Мозаичное представление блоков ландшафта Теперь, представив ландшафтную геометрию участками-блоками, мы можем обратить свое внимание на описание метрики ошибок, необходимой для управления мозаичным представлением ландшафта и фактическим процессом рендеринга. Как и при реализации метода ROAM в главе 7 «Ландшафтная система ROAM», мы вычислим метрику ошибки каждого узла дерева. Эта метрика суть максимальная ошибка смещения узла и всех потом- ков последнего. Вложенное множество метрик ошибок дает возможность рекурсивно
180 Глава 8 обойти узлы дерева и выбрать оптимальное множество геометрических форм, которые и будут показаны. В каждом узле бинарного дерева треугольников из ROAM мы были обязаны проверить только одну вершину В блочном ландшафте каждый узел представляет собой целую сетку вершин. Обходя каждую из них, мы должны найти максимум ошибки по сетке в целом. Это значение станет ошибкой смещения узла и будет использовано родительскими узлами для отыскания максимальной метрики ошибки ветви квадрадерева. Для реализации поиска максимума ошибки смещения в сетке введем функцию-член в базовый класс cTerrain. Эта функция сделает поисковую процедуру доступной методам, отличным от метода блочных ландшафтов, построением которого мы сейчас занимаемся. Эта процедура является рекурсивным сопоставлением каждого треугольника сетки и тех значений высот реального мира, который треугольник аппроксимирует. Весь про- цесс показан в листинге 8.2. Заметим, что мы используем ту же схему параметров, что И при создании основанного на сетке буфера индексов. Это дает возможность задать метрику ошибок в любой сетке вершин и при любом уровне детализации. ЛИСТИНГ 8.2. Расчет метрик ошибок в сетке вершин при заданном уровне LOD float cTerrain::computeErrorMetricOfGrid( uintl6 xVerts, // длина сетки uintl6 yVerts, // высота сетки uintl6 xStep, // число вершин в ячейке (гориз.) uintl6 yStep, // число вершин в ячейке (верт.) uintl6 xOffset, // начальный индекс по х’ uintl6 yOffset) // начальный индекс по Y float result = O.Of; int total_rows = yVerts-1; int total_cells = xVerts-1; unsigned short start—vert = (yOffset*m_tableWidth)+xOffset ; unsigned short linestep = yS tep*m_tableWidth; float invXStep = 1.Of/xStep; float invYStep = 1.Of/yStep; for (int j=0;j<total_rows;++j) { uintl6 indexA = start_vert; uintl6 indexB = start_vert+lineStep; float cornerA = readWorldHeight(indexA); float cornerB = readWorldHeight(indexB);
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 181 for (int i=0; i<total_cells;++i) { // рассчитаем две новые угловые вершины uintl6 indexC = indexA+xStep; uintl6 indexD = indexB+xStep; // извлечем значения высот двух новых вершин float cornerC = readWorldHeight(indexC); float cornerD = readWorldHeight(indexD); // настроим значения шага в каждом // из двух треугольников этой ячейки float stepXO = (cornerD-cornerA)*invXStep; float stepYO = (cornerB-cornerA)*invYStep; float stepXl = (cornerB-cornerC)*invXStep; float stepYl =(cornerD-cornerC)‘invYStep; // найдем максимальную ошибку среди всех // точек, покрытых обоими треугольниками int subindex = indexA; for (int y=0; y<yStep;++y) { for (int x=0; x<xStep;++x) { float trueHeight = readWorldHeight(subindex); ++sublndex; float intepolatedHeight; if (y < (xStep-x)) { intepolatedHeight = cornerA + (stepX0*x) + (stepY0*y); } else ( intepolatedHeight = cornerC + (stepXl*x) + (stepYl*y); } float delta = absolutevalue( trueHeight - intepolatedHeight);
182 Глава 8 result = maximum( result,delta); } subindex = indexA+(y*m_tableWidth); } // сохраним углы для работы со следующей ячейкой indexA = indexC; indexB = indexD; cornerA = cornerC; cornerB = cornerD; } start_vert += lineStep; } return result; } В объектах cChunkTerrainSection найденные по сетке вершин значения метрик ошибок сохраняются во вполне развернутом квадрадереве. Это дерево метрик является единственным представлением данных, используемым при построении квадрадерева блоков ландшафта. В отличие от реализации ROAM мы не используем здесь реальных структур с указателями, представляющих узлы дерева. Вместо этого обход по дереву мы организуем интерполяцией позиционной информации из всех четырех углов роди- тельского узла, а для проверок используем данные, хранящиеся в дереве метрик ошибок. Процесс построения дерева метрик ошибок показан в листинге 8.3. ЛИСТИНГ 8.3. Создание дерева метрик ошибок #define LEVEL_SIDE_LENGTH(i) (l«i) enum e_constants { k_minTessellationShift = 2, }; void cChunkTerrainSection::buildErrorMetricTree() { // сдвиг участка указывает, насколько // велик по числу вершин наш корневой узел int shift = m_pTerrainSystem->sectorShift(); int stride = (K<shift)+1; // эта информация служит для установки начального // значения шага и данных о численности вершин int stepSize =
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 183 stride>k_minTessellationShift; int vertCount = (l<<k_minTessellationShift)+ 1; // теперь мы можем обойти уровни детализации // представления и рассчитать метрику ошибки // каждого узла квадрадерева. Эти данные // будут сохранены в дереве метрик ошибок // в целях их применения в будущем int i; for (i=m_totalLevels-l; i>=0;--i) { int localstep = stepSize>i; int xSpan = (vertCount-1)*localStep; int ySpan = (vertCount-1)*localStep; int side_count = LEVEL_SIDE_LENGTH(i); for (int y=0; y<side_count;++y) { for (int x=0; x<side_count;++x) { // вычислим локальное значение errorMetric. // m_heightMapX и m_heightMapY есть // положения пикселов на карте высот // данного участка ландшафта float errorMetric = m_pTerrainSystem->computeErrorMetricOfGrid( vertCount, // длина сетки vertCount, // высота сетки localstep, // число вершин в ячейке (гориз.) localstep, // число вершин в ячейке (верт.) m_heightMapX+ (x*xSpan), // начальный индекс по X m_heightMapY+ (y*ySpan));// начальный индекс по Y // сравним с errorMetric потомков и найдем максимум if (i + 1 < m_totalLevels) { int nextLevel = i + 1; int nX = x<<1; int nY = y<<l; int dim = side_count<<l; errorMetric = maximum( errorMetric,
184 Глава 8 m_errorMetriCTree [nextLevel][(nY*dim)+nX]); errorMetric = maximum( errorMetric, m_errorMetricTree [nextLevel][(nY*dim)+ПХ+1]); errorMetric = maximum( errorMetric, m_errorMetricTree [nextLevel][((nY+1)*dim)+nX]); errorMetric = maximum( errorMetric, m_errorMetric.Tree [nextLevel][((nY+1)*dim)+nX+l]); } m_errorMetricTree[i][(y*side_count)+x] = errorMetric; } } } } Мозаичное представление блочного ландшафта теперь становится делом обычной проверки .каждого узла квадрадерева, представляющего ландшафтные блоки. Находясь в каждом узле, мы решаем, должны ли нарисовать сам этот узел или перейти вниз и про- анализировать все четыре его потомка. Чтобы определить, рисовать узел или продолжать обход дерева, мы, как и в реализации ROAM, используем отношение ошибки и расстоя- ния. Метрика ошибки каждого узла дерева заранее вычисляется и сохраняется в готовой для доступа таблице метрик ошибок. Тем не менее, чтобы проверить каждый узел, нам надо по-прежнему знать расстояние от него до позиции наблюдателя. Процесс начинается с анализа корневого узла cChunkTerrainSection и расчета рас- стояния до каждого из четырех углов оного. Расчет расстояния - небыстрая операция (она включает в себя извлечение квадратного корня), но в каждом кадре и на каждом участке такие издержки возникают лишь один раз. В ходе рекурсивного спуска к дочерним узлам мы не станем выполнять каких-либо расчетов расстояний до этих потомков, а интерполируем полученные четыре значения расстояний. Подобное делает возможным «сеточная» природа наших участков ландшафта. В каждом узле определим ближайший к наблюдателю угол и воспользуемся расстоя- нием до него для расчета частного ошибки и расстояния. Руководствуясь масштабом-и пре- дельным значением отношения, которые задает пользователь, мы без труда сможем опреде-
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 185 лить, следует ли отображать данный узел. Встретив требующий визуализации узел, доба- вим его в список узлов, которые должны быть нарисованы при следующем проходе рен- деринга. Основные фрагменты описанного процесса иллюстрирует листинг 8.4. ЛИСТИНГ 8.4. Поиск требующих рендеринга узлов на участке cChunkTerrainSection void cChunkTerrainSection::prepareForRender() { cCamera* pcamera = TheGameHost.activeCamera(); // вычислим 2d-no3Hunio каждого угла участка cVector2 cornerO(m_worldRect.xO, m_worldRect.yd); cVector2 cornerl(m_worldRect.xO, m_worldRect.yl); cVector2 corner2(m_worldRect.xl, m_worldRect.yl); cVector2 corners(m_worldRect.xl, m_worldRect.yO); cVector2 viewPoint= pCamera->worldPosition().vec2(); // найдем расстояние от наблюдателя до четырех углов float distanced = viewpoint.distance(cornerO); float distancel = viewpoint.distance(cornerl); float distance2 = viewpoint.distance(corner2); float distance! = viewpoint.distance(corners); // очистим список рендеринга m_totalRenderEntries=0; // рекурсивно замостим и внесем во // внутренний список рендеринга recursiveTessellate( distanceO, distancel, distance2, distances, 0, 0, 0, chunkTerrain()->lodErrorScale(), chunkTerrain()->lodRatioLimit() ) ; } void cChunkTerrainSection::recursiveTessellate( float distA, float distB, float distc, float distD, int level, int levelX, int levelY, float vScale, float vLimit) { bool split = false; // можно ли попытаться произвести разбиение? if (level+1 < m_totalLevels) { int index = (levelY*LEVEL_SIDE_LENGTH(level))+levelX; float errorMetric = m_errorMetricTree[level][index];
186 Глава 8 // найдем кратчайшее расстояние float dist = minimwn(distA, distB); dist = minimum(dist, distc); dist = minimum(dist, distD); // найдем отношение errorMetric к значению расстояния float vRatio = (errorMetric*vScale)/(dist+0.OOOlf); // если предел отношения превышен, произведем разбиение if (vRatio > vLimit) { int nextLevel = level+1; int startX = levelXccl; int startY = levelY«l; // найдем расстояния до средней точки float midAB = (distA + distB)*0.5f; float midBC = (distB + distC)*0.5f; float midCD = (distc + distD)*0.5f; float midDA = (distD + distA)*0.5f; float midQuad = (distA + distc)*0.5f; // инициируем рекурсию по четырем потомкам recursiveTessellate( distA, midAB, midQuad, midDA, nextLevel, startX, startY, vScale, vLimit); recursiveTessellate( midAB, distB, midBC, midQuad, nextLevel, startX, startY+1, vScale, vLimit); recursiveTessellate( midBC, distc, midCD, midQuad, nextLevel, startx+l, startY+1, vScale, vLimit); recursiveTessellate( midAB, midQuad, midCD, distD, nextLevel, startx+l, startY, vScale, vLimit); // запомним факт разбиения split = true; } // было ли разбиение?
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 187 if (!split) ( // внесем себя в список рендеринга if (m_totalRenderEntries < k_maxRenderEntries) { sRenderEntryk entry = m_renderList[m_totalRenderEntries++]; int lodShift = 5 - level; entry.level = level; entry.offsetX = (levelXcclodShift); entry.offsetY = (levelYcclodShift); } } } , На прилагаемом компакт-диске содержится демонстрационная программа, которая ------у иллюстрирует метод блочных ландшафтов в действии. Как и во всех подобных программах, предложенных до сих пор, в ней мы не заостряем внимание на текстуризации или эффектах рендеринга. Демонстрационная программа chapter8_demo0.exe - это лишь способ показать небольшой, созданный по случайной карте высот блочный ландшафт. Сама карта высот натянута на ландшафт как изображение в оттенках серого, призванное показать связь между попиксельно хранящимися значениями высот и финальным ландшафтом, постро- енным с учетом уровней LOD. Взаимосвязанная ландшафтная мозаика Последний метод, который мы вам представим, также использует основанный на сетке подход, но на сей раз без рекурсивных операций на дереве. Метод, впервые описанный в Game Programming Gems 2 [Snook], включает в каждую ячейку сетки набор геометрических форм, заранее построенных на разных уровнях детализации. На самом высоком уровне LOD все квадраты для представления каждого соответственного пиксела карты высот используют по целой вершине. На каждом последующем LOD половину вершин отбрасывают, что создает вариант сетки с более низким разрешением. Этот простой метод управления LOD не требует сведений о деревьях или рекурсии, работая с простым списком возможных LOD на каждом участке ландшафта и метриками ошибок, которые контролируют их внешний вид. Чтобы упростить аппаратную реализацию хранения, во всех версиях сетки исполь- зуется единый буфер вершин, при этом для порождения каждого LOD служит отдельный индексный буфер. Буферы индексов описывают конкретные LOD, используя разные
188 Глава 8 вершины из общего вершинного буфера. Два уровня детализации, построенные из одних и тех же вершин при помощи индексных буферов для описания отображаемых треугольни- ков, показаны на рис. 8.3. Рис 8.3. Применение индексных буферов для порождения разных уровней детализации из одних и тех же вершин Как и в предыдущих методах, метрика ошибки каждого квадрата на сетке находится на основании невязки между уровнями детализации представления. Коль скоро эти квадраты не сведены в иерархию, мы просто запишем в каждую из ячеек конкретное число метрик ошибок. Двигаясь между ячейками, сравним каждую метрику с нашим значением видозависимой допустимой погрешности и сделаем вывод о том, какой индекс- ный буфер мы станем использовать. Как Ульрих в методе Chunk Terrain, положим в основу выбора уровня детализации отображения не треугольник, а разрешение сетки. Впрочем, мозаичные элементы ландшафта вынуждают нас двигаться заданными шагами и не пре- дусматривают рекурсии по деревьям для дальнейшего улучшения сетки. Как всегда, мы можем столкнуться с появлением щелей И хотя для их заполнения в методе Chunked Terrain мы могли пользоваться геометрией кромок, крупный размер ячеек на нашей сетке делает их более заметными для конечного потребителя. Так что взамен нам нужно провести дополнительную работу, направленную на то, чтобы ланд- шафт имел кажущиеся бесшовными переходы между различными уровнями детализации (LOD). Для этого мы продолжим усовершенствовать буферы индексов, с тем чтобы соз- дать геометрические фрагменты, которые заделают щели и соединят элементы мозаики. Полный набор индексных буферов, созданных для конкретного мозаичного элемента, можно разбить на две группы: основную геометрию и геометрию связей. Основные фраг- менты представляют большую часть элемента мозаики при данном уровне детализации, к ним же относятся зоны, удаленные, чтобы дать место присоединяемым связующим эле- ментам. Фрагменты геометрии связи заполняют промежутки между смежными основны- ми фрагментами и образуют бесшовные переходы. Пара ячеек с различными LOD и свя- зующие фрагменты, которые их объединяют, показаны на рис. 8.4
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 189 Рис. 8.4. Два элемента ландшафта вкупе с объединяющим их фрагментом геометрии связи (показан белым) Работа с геометрией связи подразумевает наличие ряда снабженных выемками фраг- ментов основной геометрии, скажем, таких, как на рис. 8.4 справа. Если наши элементы мозаики могут соединяться с менее детализированной геометрией по четыпем сторонам, то нам для представления каждого из элементов потребуется множество из 16 индексных буферов. Коль скоро такие буферы могут хранить лишь относительные вершинные индек- сы, мы можем создать единый набор из 16 элементов, используя его в работе над всем ландшафтом. Полный набор из 16 требуемых в нашей мозаике фрагментов основной гео- метрии показан на рис. 8.5. Рис. 8.5.16 основных форм, необходимых для представления основы любого ландшафта. Открытые зонь -это пространство, где могут размещаться связующие фрагменты, соединяющие смежные ячейки сетки Число фрагментов, необходимых для связи, зависит от общего количества LOD каж- дого элемента мозаики. Потребуем, чтобы связуюшие фрагменты располагались в мозаике по сторонам, смежным с элементами с меньшей детализацией. Тем самым мы ограничим набор, включив в него лишь те связующие фрагменты, что могут переходить с одного уровня детализации на другой, более низкий. Так, на рис. 8.6 приведены два варианта одного связующего фрагмента. Каждый из них предназначен для соединения со смежным уровнем LOD, меньшим, чем уровень детализации ячейки, где содержится связь.
190 Глава 8 Рис 3.6. Пример двух фрагментов геометрии связи, созданных для присоединения смежных ячеек с более низкой детализацией _ В папке source/gaia на компакт-диске индексные буферы каждой 4—-*—' из описанных форм представлены как данные в статике. Классы cTileTerrain и cTileTerrainSection, в которых заложен этот метод работы с ландшафтом, осуществ- ляют загрузку статических данных и преобразуют их в таблицу индексных буферов, необхо- димых в процессе рендеринга. Буферы вершин мы используем те же: это двухпоточные бу- феры, реализованные в базовых классах cTerrain и cTerrainSection. В помощь этому методу не нужны никакие особые геометрические конструкции. Задание мозаичных элементов - это обычная работа с функцией расчета ошибок по сетке, которую мы построили выше (см. листинг 8.2) д 1я отыскания набора метрик ошибок на участке ландшафта. В пашем примере реализации каждый участок может ото- бражаться с применением одного из четырех возможных уровней LOD. Для выбора лучшего LOD набор из трех значений метрик ошибок участка мы вычисляем заранее. Затем в ходе рендеринга эти значения послужат для подбора надлежащего уровня детали- зации индексов. Функции задания этих данных показаны в листинге 8.5. Листинг 8.5. Задание и применение метрик ошибок в классе cTiledTerrainSection // эта функция строит таблицу метрик ошибок void cTiledTerrainSection::computeErrorMetricTable() { int vertCount =1; int stepCount = 8; for (int lod=0; lod<k_totalDetailLevels; ++lod) { // вычислим локальное значение errorMetric.
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 191 // m_heightMapX и m_heightMapY есть // положения пикселов на карте высот // данного участка ландшафта. // m_xVerts и m_yVerts - длина и // высота участка в вершинах m_errorMetric[lod] = m_pTerrainSystem->computeErrorMetricOfGrid( vertCount+1, // длина сетки vertCount+1, // высота сетки stepCount-1, // число вершин в ячейке (гориз.) stepCount-1, // число вершин в ячейке (верт.) m_heightMapX, // начальный индекс по X m_heightMapY);// начальный индекс по Y vertCount <<=1; stepCount >=1; } // убедимся, что каждая из метрик ошибок // представляет самый высокий уровень lod m_errorMetric[1] = maximum(m_errorMetric[ 1 ] , m_errorMetric[2] ) ; m_errorMetric[0] = maximum(m_errorMetric[0], m_errorMetric[1]); } // эта функция, пользуясь данными из таблицы // метрик ошибок, подбирает лучший уровень lod void cTiledTerrainSection::prepareForRender() { cCamera* pCamera = TheGameHost.activeCamera(); // вычислим 2d-no3HUHK> каждого угла участка cVector2 cornerO(m_worldRect.xO, m_worldRect.y0); cVector2 cornerl(m_worIdRect.xO, m_worldRect.yl); cVector2 corner2(m_worldRect.xl, m_worldRect.yl); cVector2 согпегЗ(m_worIdRect.xl, m_worIdRect.yO); cVector2 viewPoint= pCamera->worldPosition().vec2(); // найдем расстояние от наблюдателя до четырех углов flo’at distanceO = viewpoint.distance(cornerO); float distancel = viewpoint.distance(cornerl); float distance2 = viewpoint.distance(corner2); float distances = viewpoint.distance(согпегЗ) ; // найдем минимальное расстояние (тестовое значение) float dist = minimum(distanceO, distancel);
192 Глава 8 dist = minimum(dist, distance2); dist = minimum(dist, distance3); // убедимся, что оно имеет ненулевое значение dist = maximum(dist, O.OOOlf); // найдем наименьший достаточный уровень lod m_lod = 0; bool finished = false; float vScale = m_pTerrainSystem->lodErrorScale(); float vLimit = m_pTerrainSystem->lodRatioLimit(); while (!finished) { // найдем отношение вариации к расстоянию float variance = m_errorMetric[m_lod]; float vRatio = (variance*vgcale)/(dist); // если предел отношения превышен, выберем очередной lod if (vRatio > vLimit && m_lod+l < k_totalDetailLevels) { ++m_lod; } else { finished=true; } } } Класс cTiledTerrain содержит код, анализирующий соседей каждого cTile- —S Terrainsection и формирующий элементы очереди, ответственные за рен- деринг основных и связующих фрагментов, необходимых для рисования каждого участка ландшафта. Данный исходный код можно найти на сопровождающем книгу компакт-диске, где он оформлен как функция-член cTiledTerrain: : submitSection. В дополнение к примеру исходного кода на диске есть и пример приложения chapter8_demol.exe, который демонстрирует взаимосвязанную ландшафтную мозаику в действии. К вопросу о трещинах при смене уровней LOD Каждый из описанных методов создания ландшафтной геометрии страдает от арте- фактов растрескивания. Они возникают, когда в той или иной зоне ландшафта происходит смена уровня замощения, что вызывает видимую «трещину» на сетке рельефа. Меньше всего среди представленных методов подобные артефакты заметны в методе ROAM, что
МЕТОДЫ МОЗАИЧНОЙ ГЕОМЕТРИИ 193 объясняется достаточно гладкой природой смен LOD на уровне треугольников. Из двух подходов, основанных на операциях с сеткой, Chunked Terrain менее склонен к появлению трещин опять же благодаря более высокому разрешению, которым он обязан исполь- зуемой в нем иерархии типа «дерево». Наиболее заметны артефакты растрескивания при работе с методом взаимосвязанной ландшафтной мозаики (Interlocking Terrain Tiles), что обусловлено гораздо более низким разрешением сетки и спонтанным возникновением связующих геометрических форм между различными квадратами сетки. Лучший способ справиться с артефактами всех трех методов - анимация, или времен- ной морфинг изменений, наблюдаемых на границе различных LOD. Он нивелирует резкую природу артефактов и делает переходы между более и менее детально представленными зонами сетки намного более гладкими. Этот подход можно с огромным успехом применять в методе Chunked Terrain, где он практически устраняет большинство трещин. В методе взаимосвязанной ландшафтной мозаики морфинг с меньшей вероятностью даст желаемый результат. Спонтанное возникновение фрагментов геометрии связи будет вести к образова- нию трещин даже при попытке использовать морфинг. Эти связующие фрагменты исполь- зуют вершины ландшафта не так, как регулярная сетка, и включают иные вершины в состав треугольников, которые образуют связи. Такие новые треугольники меняют способ интерпо- ляции значений высот на промежутках между вершинами, что делает невозможным созда- ние набора образов морфинга, пригодных в любой ситуации. По этой причине реализация морфинга в методе взаимосвязанных ландшафтных мозаик представляется нереальной. Если, чтобы скрыть трещины, вы используете образы морфинга, то подготовка к нему во всех методах заключается в одном и том же. На каждом уровне детализации начните с позиций вершин, соответствующих поверхности на предыдущем уровне детального пред- ставления. По мере движения наблюдателя медленно интерполируйте значения, лежащие между этими исходными точками и вашим истинным положением. Со временем вершины переместятся со своих прежних позиций по высоте в нужные точки, указанные сеткой высот, что устранит эффект трещин при резкой смене детализации. Чтобы упростить код и сделать смены LOD более ясными, мы не пытаемся использовать морфинг на стыке уровней детали- зации в тех примерах программ, которые предложены в этой книге. Литература [Snook] Snook, G. «Simplified Terrain Using Interlocking Tiles». Game Programming Gems 2, p. 377-383, Charles River Media, Inc., 2001. [Ulrich] Ulrich, Thatcher. «Chunked LOD» (работа доступна по адресу http://tulrich.com/ geekstuff/ chunklod. html). 7 - 1КЧО
Глава 9 МЕТОДЫ ТЕКСТУРИЗАЦИИ В предшествующей главе мы создали простой набор ландшафтных геометрических форм. Отобразив их каркас, мы можем наблюдать методы управления уровнями детализа- ции (LOD) ландшафта в действии и начать мысленно представлять себе итоговый вид пейзажа. Следующая стоящая перед нами задача - текстуризация рельефа при помощи реалистичных изображений разных земных поверхностей. В нашей книге мы будем говорить лишь о нескольких основных поверхностях: траве, грунте, скальной породе и снежном покрове. На устаревшей аппаратуре мы можем еще сильнее сузить этот набор, если число доступных текстур в расчете на один пиксельный шейдер окажется существен- но ограничено; подробнее об этом - далее в этой главе. В центре внимания этой главы будет изучение отдельных методов текстуризации рельефа, а также их достоинства и недостатки. Памятуя о процедурной природе карт шума, послуживших для создания геометрии нашего ландшафта, мы рассмотрим и про- цедурные методы генерации текстур поверхностей. Это позволит нам сделать еще один шаг на пути к ландшафтам, генерируемым случайным образом. Как и при работе с самой геометрией ландшафта, мы, стремясь достичь большей гибкости, можем использовать как данные, подготовленные вручную, так и процедурные решения своей задачи. Большой-большой расплывчатый мир Для начала мы сразу отбросим простейший метод - натянуть единственную текстуру на весь ландшафт. Хотя он и практичен с точки зрения отображения, качество получен- ного рисунка дает нам полное право быстро отвергнуть это решение. Удобный способ измерить визуальное качество - найти отношение числа пикселов текстуры (текселов) к числу пикселов на экране при данном методе текстуризации. Представьте себе, к при- меру, участок ландшафта, заполняющий собой поле зрения камеры. Если на эту гео- метрическую конструкцию мы наложим небольшую текстуру, то билинейная фильтрация текстуры приведет к появлению на экране больших расплывчатых цветовых пятен.
МЕТОДЫ ТЕКСТУРИЗАЦИИ 195 Чем больше разрешение текстуры, тем более четким окажется результат; иными словами, частное количества текстурных и экранных пикселов растет - качество повышается. Наша цель - создать ландшафт, который растянется не на одну милю. Когда же созда- ется ландшафт, способный занять такую огромную площадь, текстура, необходимая, чтобы его покрыть при сохранении приемлемого отношения числа текстурных и экран- ных пикселов, будет чересчур велика. Даже ландшафт, который должен представлять только одну квадратную милю, потребовал бы при масштабе 1 пиксел на квадратный фут использовать текстуру в 5280 х 5280 пикселов! Поэтому мы вынуждены обратиться к ре- шениям, покрывающим ландшафт множеством карт текстур, а не одной большой картой, х'- Не стоит и говорить, что пользоваться одной большой картой текстур, чтобы ч~~---- накрыть ландшафт, неразумно - этот подход отличался бы немалой прямоли- нейностью. Но даже если вблизи камеры и применяются более робастные методы текстуризации, на удаленных участках ландшафта мы можем использовать большую текстурную карту. Строить текстуры для работы с ландшафтом помогает служебная про- грамма Т2, расположенная на прилагаемом компакт-диске. Она дает возможность пере- дать ей карту высот, а также множество свойств желаемого ландшафта. После чего она может создать текстуру любого размера, пригодную для наложения на ландшафт. Кроме того, в Т2 реализована функция деления этой большой текстуры на меньшие по размеру фрагменты. Если вы используете такой метод ландшафтной геометрии на основе сеток, как Chunked LOD (Блочные уровни детализации), то иметь утилиту синтеза текстур для при- менения их в отдельных зонах ландшафта может оказаться очень и очень полезно. Более подробные сведения о Т2 и ее авторе Ките Дитчберне (Keith Ditchbum) вы можете найти на Web-сайте, указанном в приложении D «Рекомендуемая литература». Читателям, желающим использовать ранее созданные ландшафтные текстуры, мы очень рекомендуем программу Т2. Однако в нашей книге мы будем и дальше делать акцент на процедурных методах синтеза случайных ландшафтов. Сам по себе движок безразличен к источнику карт высоты, поэтому выход написанной Дитчберном программы Т2 может быть легко встроен в систему. Теперь, когда проблема предварительной подготовки текстур уже не стоит, нам надо найти программный подход к решению этой задачи. Первая идея, которая, должно быть, пришла вам в голову, - представить текстуру в виде элементов мозаики. Так, если бы мы создали единую карту текстур и не один раз повторили ее в пределах ландшафта, то могли бы получить гораздо более высокое отно- шение числа текстурных пикселов и пикселов на экране. Все верно, но этот прием будет работать лишь на однородных поверхностях. Желая создать ландшафт, занятый на 100% травой, мы, в самом деле, могли бы полностью покрыть его травяной текстурой высокого разрешения. Однако любой повтор узоров травы, каким бы малозаметным он ни был, быстро обнаружится при взгляде на повторяющиеся участки вдали. После небольшой
196 Глава 9 художественной доработки этот эффект можно практически свести на нет при условии, что акр за акром вы хотите получать одну и ту же поверхность. Еще одним возможным решением является подход, основанный на участках. В этом случае мы воспользуемся множеством уникальных текстур, упорядоченных в виде натя- нутой над ландшафтом двухмерной сетки. Каждая текстура представляет лишь малую часть ландшафта; увеличив же разрешение этих текстур, мы можем поддерживать высо- кое значение отношения числа пикселов. В известном смысле этот метод берет большое изображение- 5280 х 5280, речь о котором шла ранее, и делит его на меньшие по размеру фрагменты. Результат тот же, при этом для эмуляции огромного рисунка используется большое число текстур. Выше мы уже говорили, что такие текстуры участков, помимо прочего, способна создавать программа Т2. На практике этот подход может прекрасно работать. Однако есть несколько «подвод- ных камней», с которыми мы вынуждены бороться. Во-первых, необходимо контролиро- вать число текстур, используемых в конкретной зоне. Если для отображения вида, который открывается через объектив камеры, нужно много текстур, мы можем сильно перегрузить менеджер ресурсов DirectX, который должен непрерывно подкачивать эти текстуры в видеопамять. Если последняя вмещает все необходимые текстуры, таких издержек не будет. Однако если мы используем данных больше, чем видеопамять спо- собна в себя вместить, то менеджер ресурсов DirectX начнет подкачку текстур по мере того, как мы их используем, выталкивая старые текстуры для освобождения места. Это может привести к ситуации пробуксовки (thrashing), когда ресурсы непрерывно пере- даются видеокарте для удовлетворения потребностей процесса рендеринга. Если объем видеопамяти не критичен, то остается возможность возникновения швов между изображениями текстуры. Даже в том случае, когда обе текстуры созданы в про- грамме рисования и превосходно ложатся рядом, возможность образования шва при нало- жении фильтра по-прежнему существует. Методы билинейной и анизотропной фильтрации для усиления или ослабления текстур, сэмплированных пиксельным шейдером, рассчиты- вают итоговое значение цвета, взяв несколько пикселов изображения и смешав их до по- лучения окончательного результата. Эти фильтры по мере приближения к камере скорее делают текстуру размытой, не позволяя ей приобрести «блоковую» структуру. Однако фильтрация на уровне отдельной текстуры значит, что в операции смешения цвета мы не задействуем пикселы смежных текстур. В ходе фильтрации края смежных текстур получат такие цветовые значения, которые не обязательно соответствуют результатам фильтрации их соседей. Это может породить видимый шов между двумя текстурами, в обычных усло- виях способными бесшовно располагаться рядом друг с другом. Этот эффект становится еще сильнее в случае ошибочного применения режимов оборачивания текстур (назначения
МЕТОДЫ ТЕКСТУРИЗАЦИИ _________ 197 одной или нескольким текстурным координатам статуса D3DTA_WRAP), вынуждающего текстурный фильтр для получения результата брать на смешение пикселы с противополож- ного края текстуры. Уменьшить частоту появления швов может ограничение адресов текстуры (d3DTA_clamp) , а не их оборачивание; полное же решение проблемы чуть менее триви- ально и требует трудоемкой подготовки текстур к работе. Для обеспечения успешной фильтрации текстуры вместе с ее соседями по внешним краям каждой текстуры должны быть размещены смежные пикселы соседних с нею текстур. Просто наложив UV-коорди- наты, которые служат для отображения любой текстуры на ее геометрию, мы сможем гарантировать, что краевые пикселы всегда будут скрыты от глаз, но доступны сэмплеру текстур при фильтрации. Это требует знать, каковы точные размеры текстур, что позволит наложить UV-координаты ровно на один пиксел внутрь от реального края изображения. В зависимости от степени фильтра, который будет наложен, границы в один пиксел Может быть недостаточно. Если применяемые текстуры должны подвергаться сильной фильтрации, возможно, потребуется и более толстая граница. Так произойдет в случае, когда текстуры «отступают назад», и набор точек, включенных в выборку фильтром, начинает выходить за пределы границы в один пиксел. В силу возможности появления этой пробле- мы, потребности во времяемкой инициализации этого метода, а также ввиду того, что ему потребуется тот же общий объем памяти, что и предложенному изображению 5280 х 5280, откажемся и от этого решения и поищем для нашего пейзажа что-нибудь более подходящее. Впрочем, дискуссия о нежелательных приемах работы не прошла для нас даром. Создание наложения текстур поверхности Покрытие всего ландшафта одним и тем же изображением приводит к однообразному результату, но это - шаг в правильном направлении. Среди обсуждавшихся нами до сих пор методов это бесспорный фаворит в плане потенциального качества и отношения числа пикселов в текстуре и на экране, необходимого объема памяти и простоты примене- ния. Если же мы, так или иначе, сможем включить в него работу с поверхностями раз- личных типов, то, разумеется, получим более привлекательный метод. Фактически решение, которое мы рассмотрим, будет содержать неоднократное наслоение результатов применения этой простой методики. Представьте, что мы покрыли ландшафт не только текстурой травы, но и другими текстурами, такими, как грунт и поро- да. Тем самым мы создадим отдельные слои ландшафта, один из которых будет покрыт грунтом, другой - скальной породой, а третий - травой. Каждый из них - это всего лишь одна и та же текстура, которая повторяется до бесконечности, но смесь которых дает очень органичные результаты.
198 Глава S Идея состоит в том, чтобы на разных участках ландшафта обеспечить неодинаковое отображение этих слоев, создавая более естественный вид. Одним частям ландшафта можно дать выглядеть более «травянисто», в то время как на других могут быть в большей степени видны скалы или заметен подстилающий грунт. Все, что нам нужно, - это средст- во управления долей каждого слоя в окончательном результате. Чтобы управлять внешним видом каждого слоя, мы можем использовать взвешенное среднее, рассчитанное для каждого пиксела. Припишем каждому слою весовое значение от 0,0 до 1,0, определяющее, в какой мере этот слой виден в данной конкретной точке. Если все веса выбраны так, что в сумме по всем слоям они дают 1,0, мы без труда можем найти итоговое значение цвета каждого пиксела. Для этого умножим каждую входную текстуру на коэффициент ее видимости и сложим полученный результат. Им является ландшафт с высоким значением частного числа пикселов в текстуре и на экране и обладающий способностью управлять внешним видом поверхностей разных типов на уровне пикселов. Графически этот процесс показан на рис. 9.1. Здесь представлены четыре входные текстуры. Среди них - текстуры поверх- ностей трех разных типов и управляющее смешением изображение. На нем коэффициент видимости каждой ландшафтной поверхности закодирован красным, зеленым и синим каналами соответственно. Создать же окончательное изображение можно, умножив каждую текстуру на соответствующий канал текстуры смешения и сложив результат. На языке пиксельных шейдеров то, что мы сделали, - это простой многотекстурный шейдер, являющийся идеальной отправной точкой в работе над нашим ландшафтом. Версия этого шейдера для нужной платформы сама определит, сколько текстур мы сможем смешать за проход, пока же мы будем работать с неким подобием общего знаменателя, который в нашем случае составляет четыре текстуры. Для однопроходного рендеринга простого ландшафта в первом методе текстуризации мы будем использовать все четыре текстуры. . На секунду предположим, что у нас есть три текстуры поверхностей: трава, порода и грунт, - а также четвертая текстура, содержащая доли каждой из них при смешении в каналах красного, зеленого и синего цвета. Пользуясь пиксельным шейдером, представ- ленным в листинге 9.1, эти текстуры можно смешать и получить окончательный вид ланд- шафта, показанного на рис. 9.2. Все, что нам надо, - создать саму текстуру смешения, которую можно получить, воспользовавшись любой удобной программой рисования или прибегнув к процедурной методике.
МЕТОДЫ ТЕКСТУРИЗАЦИИ 199 Текстура травы Текстура скальной породы Текстура грунта х х х Итоговое составное изображение Рис. 9.1. Три входные текстуры, представляющие траву, скалы и грунт, в сочетании с отдельными цветовыми каналами текстуры смешения дают итоговый результат ЛИСТИНГ 9.1 Простой пиксельный шейдер для создания смеси слоев трех поверхностей при помощи четвертой текстуры смешения float4 ThreeSurfaceBlend(VS_OUTPUT In) : COLOR { II сэмплируем все четыре текстуры float4 BlendControler = tex2D(LinearSampO, In.vTexO ) float4 texColorO = tex2D{LinearSampl, In.vTexl ); float4 texColorl = tex2D(LinearSamp2, In.vTex2 ); float4 texColor2 =
200 Глава 9 tex2D(LinearSamp3, In.vTex3 ); // определим долю каждой поверхности при смешении float4 ColorO = (texColorO * BlendControler.г); float4 Colorl = (texColorl * BlendControler.g); float4 Color2 = (texColor2.* BlendControler.b); // сложим полученные цвета и умножим // на диффузную составляющую цвета // вершины (освещение) return (ColorO + Colorl + Color2) *BlendControler.а *In.vDiffuse; } Создать текстуру смешения вручную совсем несложно, достаточно представить в оттенках серого того или иного канала текстуры искомую долю каждой из наших поверхностей. При этом следует уделить внимание тому, чтобы во избежание пересыще- ния ландшафта при совмещении текстур всех поверхностей коэффициенты смешения в сумме давали 255 (белый цвет). Однако для упрощения синтеза случайных ландшафтов нам потребуется метод создания такой текстуры смешения во время работы программы. Класс cTerrain содержит немало информации о ландшафте. Для каждого пиксела исходной карты высот он хранит значение высоты и вектор нормали к поверхности. Эти данные мы можем использовать для генерации текстуры смешения, определяя высоты и углы наклона там, где ожидаем найти ландшафт определенного типа. Путем анализа данных, находящихся в cTerrain, и их сравнения с желаемыми значениями наклона и высоты мы можем нарисовать текстуру смешения поверхностей при помощи процедуры. Это очень похоже на те функции, которые в режиме автономной работы выполняет утилита Т2. Главное же отличие состоит в том, что Т2 выдает итоговую текстурную карту, а мы хотим создать только текстуру смешения, необходимую, чтобы построить финальную карту текстур в реальном масштабе времени. Создание текстуры смешения поверхностей при том разрешении, какое имеет исход- ная карта высот, дает возможность увидеть, что отношение объема данных, хранящихся в cTerrain, и окончательной текстуры смешения составляет 1:1. Однако реальной необ- ходимости в том, чтобы накладывать такое ограничение на размер данной текстуры, не существует. Создав ряд простых функций интерполяции хранящихся в cTerrain данных о вершинах, мы можем построить текстуру любого размера, равного или превы- шающего размер исходной карты высот.
МЕТОДЫ ТЕКСТУРИЗАЦИИ 201 Текстуры смешения с разрешением меньше размеров карты высот мы не будем созда- вать по двум причинам. Во-первых, текстура меньшего разрешения серьезно сужает про- странство маневра при объединении ландшафтных поверхностей. Это создает возмож- ность возникновения крупных, похожих на блоки зон поверхностей каждого типа. Во- вторых, интерполяция многих нормалей к поверхностям из числа наших ландшафтных данных даст неверный наклон, что позволит нам разместить поверхности ландшафтов там, где обычно мы и не думаем их увидеть. Добавим в cTerrain несколько функций интерполяции данных о ландшафте на уровне отдельных вершин. Эти функции, показанные в листинге 9.2, осуществляют выборку четырех вершин, окружающих данную точку, и производят интерполяцию с получением результата. В случае интерполяции нормалей к поверхностям необходимо заново нормиро- вать результат, ибо такая интерполяция способна привести к созданию векторов, длина которых уже отлична от единичной; иными словами, при вычислении эти векторы теряют нормированный характер. Листинг 9.2. Функции интерполяции высот и нормалей к поверхностям, реализованные в классе cTerrain float cTerrain : : calcMapHeight( float mapX, float mapY)const { float fMapX = mapX * (m_tableWidth-l); float fMapY = mapY * (m_tableHeight-l); int iMapXO = realTo!nt32_chop(fMapX); int iMapYO = realTo!nt32_chop(fMapY); fMapX -= iMapXO; fMapY - = iMapYO; iMapXO = clamp(iMapXO, 0, m_tableWidth-1); iMapYO = clamp(iMapYO, 0, m_tableHeight-l); int iMapXl = clamp(iMapXO+1, 0, m_tableWidth-l); int iMapYl = clamp(iMapYO+1, 0, m_tableHeight-l); // прочитаем четыре имеющихся на карте значения float h0 = readWorldHeight(iMapXO, iMapYO); float hl = readWorldHeight(iMapXl, iMapYO); float h2 = readWorldHeight(iMapXO, iMapYl); float h3 = readWorldHeight(iMapXl, iMapYl); float avgLo = (hl*fMapX) + (hO*(1.0f-fMapX)); float avgHi = (h3*fMapX) + (h2*(1.Of-fMapX)); return (avgHi*fMapY) + (avgLo*(1.0f-fMapY));;
202 Глава 9 void cTerrain ::calcMapNormal( cVector3& normal, float mapX, float mapY)const float fMapX = mapX * (m_tableWidth-l) ; float fMapY = mapY * (m_tableHeight-l); int iMapXO = realTo!nt32_chop(fMapX); int iMapYO = realTo!nt32_chop(fMapY); fMapX -= iMapXO; fMapY -= iMapYO; iMapXO = clamp(iMapXO, 0, m_tableWidth-1); iMapYO = clamp(iMapYO, 0, m_tableHeight-l); int iMapXl = clamp(iMapXO+1, 0, m_tableWidth-l); int iMapYl = clamp(iMapYO+1, 0, m_tableHeight-l); // прочитаем четыре имеющихся на карте значения из нашей // таблицы данных, хранящихся на уровне отдельных вершин cVector3 h0 = readWorldNormal(iMapXO, iMapYO); cVector3 hl = readWorldNormal(iMapXl, iMapYO); cVector3 h2 = readWorldNormal(iMapXO, iMapYl); cVector3 h3 = readWorldNormal(iMapXl, iMapYl); // усредним результат cVector3 avgLo = (hl*fMapX) + (hO*(1.Of-fMapX)); cVector3 avgHi = (h3*fMapX) + (h2*(1.Of-fMapX)); normal= (avgHi*fMapY) + (avgLo*(1.Of-fMapY)); // повторно нормируем результат normal.normalize(); Интерполяция высот ландшафта и нормалей к его поверхности лишь малая часть стоя- щей перед нами задачи. Теперь мы должны описать способ управления внешним видом трех потенциальных типов поверхностей на основе их высоты и наклона. На деле это намного проще, чем кажется. Сначала опишем наши управляющие параметры как неболь- шую структуру. Такая структура будет организована для каждой из текстур, которую мы захотим включить в смесь. Найти эту структуру можно в описании класса cTerrain. struct elevationData float minElevation; // низшая высотная отметка float maxElevation; // высшая высотная отметка
МЕТОДЫ ТЕКСТУРИЗАЦИИ 203 float minNormalZ; // мин. z-коорлината нормали к поверхности float maxNormalZ; // макс, z-координата нормали к поверхности float strength; // совокупная "интенсивность" (приоритет) }; Как показывает структура elevationData, каждой ландшафтной поверхности мы даем возможность указать свой минимум и максимум как в диапазоне высот, так и в диапазоне значений наклона. Наклон измеряется z-составляющей нормали к поверх- ности. Значения z, близкие к 1,0, описывают вертикаль и соответствуют плоским участкам ландшафта, в то время как меньшие значения z-координаты нормали к поверхности харак- теризуют более крутой склон. Задание этих значений в диапазоне от 0,0 до 1,0 позволит отобразить поверхность ландшафта в хребты скал (малые значения z) и участки равнины (большие значения z). Класс cTerrain содержит функцию-член, способную создать текстуру смешения, используя в качестве входа до четырех структур elevationData. Для каждого пиксела этой текстуры функция делает выборку значения высоты и нормали к поверхности ланд- шафта и определяет вклад каждого типа поверхности при смешении. Чтобы рассчитать коэффициенты смешения, она находит отдельные весовые значения, которые соответст- вуют диапазонам высот и значений наклона, и объединяет результаты с учетом совокуп- ной интенсивности поверхности каждого типа. Расчет весовых значений основан на минимальных и максимальных значениях диапа- зона, заданных для наклона и высоты. Точкам ландшафта, попадающим в центр мини- максного диапазона, припишем вес 1,0. Точки, оказавшиеся в пределах диапазона между минимумом и максимумом, получат значения весов, стремящиеся к нулю. Вес точек вне минимаксного диапазона равен нулю. Когда весовые значения для высоты и наклона най- дены, результат объединяется и умножается на совокупную интенсивность поверхности. Так строится коэффициент смешения для анализируемой поверхности. Эта процедура повторяется в отношении всех входных структур elevationData, позволяя определить итоговое значение пиксела текстуры смешения. Однако мы должны наложить ограничение на все коэффициенты смешения данного пиксела, с тем чтобы в сумме они давали значение 1,0. Это послужит гарантией того, что наш шейдер будет способен объединить эти текстуры, не беспокоясь о пере- или недона- сыщении окончательного изображения. Чтобы ввести это правило, получим сумму всех значений весов, рассчитанных для каждого пиксела текстуры смешения. Прежде чем записывать реальные коэффициенты смешения в конкретный пиксел, разделим их значе- ния на эту сумму. Так как в делении участвует каждый из коэффициентов, мы можем быть уверены в том, что их сумма будет равна 1,0. Этот процесс опять же гораздо проще, чем могло показаться. Функция, реализующая описанные нами шаги, приведена в листинге 9.3. О простоте вычислений свидетельствует
204 Глава 9 беглый просмотр ее кода. Заметим, что хотя коэффициенты смешения и определяются в диапазоне между 0,0 и 1,0, при записи в текстуру они преобразуются в цветовые значе- ния из диапазона (0-255). ЛИСТИНГ 9.3. Создание текстуры образа смешения static float computeWeight( float value, float minExtent, float maxExtent) { float weight = O.Of; if (value >= minExtent && value <= maxExtent) { float span = maxExtent - minExtent; weight = value - minExtent; // переведем в значения между // 0 и 1 с учетом расстояния // до средней точки диапазона weight *= 1.Of/span; weight -= 0.5f; weight *= 2. Of; // возведем результат в квадрат для // получения нелинейной характеристики weight *= weight; // проинвертируем результат и проверим // его принадлежность диапазону weight = 1.Of-absoluteValue(weight); weight = clamp(weight, O.OOlf, l.Of); } return weight; } void cTerrain ::generateBlendlmage( cimage* pBlendlmage, elevationData* pElevationData, int elevationDataCount) bool success = false;
МЕТОДЫ ТЕКСТУРИЗАЦИИ 205 int х,у,i; // убедимся в том, что структур не более четырех elevationDataCount = minimum(elevationDataCount, 4); // определим размеры образа смешения int image_width = pBlend!mage->width(); int image_height = pBlendlmage->height(); // вычислим значение шага // uv-координат образа float uStep = 1.Of/(image_width-l); float vStep = 1.Of/(image_height-l); // значения этих четырех масок управляют тем, // в какую цветовую составляющую образа // смешения мы будем производить запись cVector4 mask[4]; mask[0].set(1.Of,0.Of,0.Of,0.Of); mask[1].set(0.0f,1.0f,O.Of,O.Of); mask[2] .set (0 . Of,0.Of,1.Of,0.Of); mask[3],set(0.0f,0.0f,0.0f,1.0f); // полностью блокируем образ pBlendlmage->lock(); // организуем пошаговую итерацию и рассчитаем каждый из пикселов for (у=0; y<image_height; ++у) { for (х=0; x<image_width; ++х) { float totalBlend = O.Of; cVector4 blendFactors( O.Of, O.Of, O.Of, O.Of); // получим значение высоты и нормали к поверхности float u = x*uStep; float v = y*vStep; float map_height = calcMapHeight(и,v); cVector3 normal; calcMapNormal(normal, u, v); // проанализируем все структуры elevationData // и рассчитаем вес каждой из них for (i=0; i<elevationDataCount; ++i) {
206 Глава 9 // рассчитаем вес по высоте float elevationScale = computeWeight( map_height, pElevationData[i].minElevation, pElevationData [i].maxElevation); // рассчитаем вес по наклону float slopescale = computeWeight( normal.z, pElevationData[i].minNormalZ, pElevationData[i].maxNormalZ); // объединим оба значения с относительной // интенсивностью этого типа поверхностей float scale = pElevationData[i].strength * elevationScale * slopeScale; // запишем результат в нужный канал // вектора коэффициента смешения blendFactors += mask[i]*scale; // и запомним совокупное значение веса totalBlend += scale; } // сбалансируем данные (чтобы их сумма составляла 255) float blendScale = 255.Of /totalBlend; // далее, рассчитаем фактическое // значение цвета, умножив каждый // канал на масштаб при смешении blendFactors *= blendScale; // наложим ограничения и преобразуем в значения цвета uint8 г = (uint8) clamp( blendFactors.х, O.Of, 255. Of); uint8 g = (uint8) clamp( blendFactors.y, O.Of, 255.Of);
МЕТОДЫ ТЕКСТУРИЗАЦИИ uint8 b = (uint8) clamp( blendFactors.z, O.Of, 255.Of); uint8 a = (uint8) clamp( blendFactors .w, 0 . Of, 255.Of); // упакуем и запишем 32-разрядное значение пиксела uint32 color = (а<<24)+(r<<16)+(g<<8)+b; pBlend!mage->setColor(х, у, color) ; } } // разблокируем образ pBlend!mage->unlock() ; } В листинге 9.3 вы, вероятно, заметили, что текстура смешения фактически является объектом класса cimage, а не cTexture. cTexture - класс, которым мы обычно поль- зуемся для загрузки и создания данных об изображениях, подлежащих рендерингу; сейчас же ввиду необходимости записать ключевую информацию в конкретные цветовые каналы текстуры смешения мы используем класс cimage . cimage - простой класс для манипу- ляций поверхностью известного нам формата, которую мы называем «образом» и которая находится в обычной (не видео-) памяти DirectX. В то время как объекты cTexture могут иметь любой формат, допустимый видеокартой (включая форматы со сжатием и в про- странстве цветов YUV), формат объектов cimage ограничен 8-, 24- и 32-разрядными RGB-изображениями с известным порядком каналов цвета. Это упрощает их чтение и обработку в сравнении с текстурой, которая способна иметь некий неизвестный формат цветовых данных. Кроме того, текстуры cimage могут находиться исключительно в сис- темной памяти, что предотвращает любые возможные задержки в работе, вызванные бло- кировкой и обновлением данных этих текстур. Когда объект cimage построен, его можно использовать как текстуру, передав члену класса cTexture - функции uploadimage. Она загружает в текстуру данные об образе, преобразует или при необходимости меняет масштаб любого цветового пространства, и все - при помощи вызова'ОЗОХ-функции D3DXLoadSurfaceFromSurface . Последняя же реализует конверсию и масштабирование цветов, необходимые, чтобы загрузить образ в текстуру для дальнейшего применения в ходе процесса рендеринга. Используя шейдер из листинга 9.1 и образ смешения, полученный в коде лис- ----' тинга 9.3, мы уже можем отобразить простейший ландшафт, затратив на это
208 Глава 9 один проход практически на любой видеокарте. Весь процесс показан в листинге 9.4, который содержит функцию InitDeviceObjects из файла main.cpp программы chapter9_demo0. Эта демонстрационная программа находится на компакт-диске, который прилагается к книге. ЛИСТИНГ 9.4. Общая схема процесса создания простейшего ландшафта, имеющего случайный характер HRESULT cMyHost::InitDeviceObjects() { cGameHost: :InitDeviceObj ects(); // построим корневой узел для // нашей сцены и камеры m_rootNode.create() ; m_camera.create(); m_camera.attachToParent(&m_rootNode); // настроим простейшую камеру m_CameraPos.set(0.Of, O.Of, lO.Of); m_camera.orientation().setRotation( cVector3(l.Of,O.Of,O.Of) , cVector3(O.Of,O.Of,l.Of) ) ; m_camera.setProj Params( D3DX_PI/5'. Of, 800.Of/600.Of, l.Of, 1000.Of); // создадим случайную карту высот m_pHeightMap = displayManager() .texturePool() .createResource(cString("height map")); m_pHeightMap->createTexture( 128, 128, 1, 0, D3DFMT_A8R8G8B8, D3DPOOL_MANAGED) ; m_pHeightMap->generatePerlinNoise( O.Olf, 5, 0.6f); // построим по этой карте ландшафт m_terrainSystem.create( &m_rootNode, m_pHeightMap, cRect3d(-500.Of,
МЕТОДЫ ТЕКСТУРИЗАЦИИ 209 500.Of, -500.Of, 500.Of, -250.Of, 250.Of), 3) ; // загрузим метод рендеринга m_pRenderMethod = TheGameHost .displayManager() .renderMethodPool() .createResource("terrain method"); m_pRenderMethod->loadResource( ”media\\shaders\\simple_terrain.fx"); // создадим три структуры высотных отметок cTerrain ::elevationData elevation[3]; // трава (любые значения высоты, произвольные склоны) elevation[0].minElevation = -250; elevation[0].maxElevation = 250; elevation[0].minNormalZ = -l.Of; elevation[0].maxNormalZ = l.Of; elevation[0].strength = l.Of; // порода (любые значения высоты, крутые склоны) elevation[l].minElevation = -250; elevation[l].maxElevation = 250; elevation[l].minNormalZ = O.Of; elevationfl].maxNormalZ = 0.85f; elevation[l].strength = 10.Of; // грунт (большие значения высоты, пологие склоны) elevation[2].minElevation = 50; elevation[2].maxElevation'= 250; elevation[2].minNormalZ = 0.75f; elevation[2].maxNormalZ = l.Of; elevation[2].strength = 20.Of; // создадим образ смешения cimage* pBlendlmage; pBlendlmage = displayManager() .imagePool() .createResource(cString("image map 3")); pBlend!mage->create(
210 Глава 9 256, 256, cimage::k_32bit); m_terrainSystem.generateBlendlmage( pBlendlmage, elevation, 3); pBlend!mage->randomChannelNoise( 3, 200, 255); // перегрузим образ смешения в текстуру m_pBlendMap = displayManager() .texturePool() .createResource(cString("image map")); m_pBlendMap->createTexture( 256, 256, 1, 0, D3DFMT_A8R8G8B8, D3DPOOL_MANAGED); m_pBlendMap->upload!mage(pBlendlmage); safe_release(pBlendlmage); // загрузим текстуры земных поверхностей m^pGrass = displayManager() . texturePool() .createResource(cString("grass" ) ) ; m_pRock = displayManager() .texturePool() .createResource(cString("rock")); m_pDirt = displayManager() .texturePool() .createResource(cString("dirt")); m_pGrass->loadResource( "media\\texturesWgrass.dds"); m_pRock->loadResource( "media\\textures\\rock.dds") ; m_pDirt->loadResource( "media\\texturesWdirt.dds") ; // создадим материал поверхности // и загрузим в него наши текстуры
МЕТОДЫ ТЕКСТУРИЗАЦИИ 211 m_pSurfaceMaterial = displayManager() . surfaceMaterialPool() .createResource(cString("ground material")); m_pSurfaceMaterial->setTexture(O, m_pBlendMap); m_pSurfaceMaterial->setTexture(1, m_pGrass); m_pSurfaceMaterial->setTextupe(2, m_pRock); m_pSurfaceMaterial->setTexture(3, m_pDirt); // назначим ландшафту метод рендеринга и материал m_terrainSystem.setRenderMethod(m_pRenderMethod); m_terrainSystem.setSurfaceMaterial(m_pSurfaceMaterial); return S_OK; Природа и шум Ограничением нашей однопроходной ландшафтной системы является ее неспособность работать с поверхностями более чем трех типов. Предел - для этого простого метода он составляет четыре текстуры на каждый проход - мы задали, чтобы сохранить совместимость с устаревшими видеокартами. Однако внимательный читатель заметит, что мы задейство- вали все четыре канала текстуры смешения. Так почему же для смешения трех текстур мы пользуемся четверкой каналов? Да потому, что у нас есть такая возможность. Повторы текстур лишают визуальной притягательности любой ландшафтный движок. Сама природа - вещь очень органичная и не повторяющая себя, тогда как наш движок не таков. Простейший способ побороть периодическую природу текстур наших поверхностей - ввести некий случайный шум. Как только у нас появилась возможность добавить в ландшафт хоть небольшую случайность, мы с нескрываемой радостью ею вос- пользовались. Так произошло с четвертым каналом нашей текстуры смешения. Простому ландшафтному шейдеру, чтобы смешать изображения поверхностей, нужно лишь три канала, так чю оставшийся, четвертый, канал мы заполняем неким случайным шумом. Вернувшись к листингу 9.4, вы обратите внимание на вызов функции randomchannel- Noise класса cimage. Эта функция заносит случайные значения в третий (альфа-)канал образа смешения изображений. Вновь обратившись к рис. 9.1, можно заметить, что альфа- значение служит для модуляции итоговых цветов, которые создает шейдер. Внеся в этот канал образа смешения некие случайные числа, мы можем ввести в ландшафт бес- повторные случайные элементы. Чтобы усилить этот эффект, попробуйте расширить диа- пазон самих случайных значений.
212 Глава 9 Компоновка буфера кадра Имея больше каналов текстур при каждом проходе, мы, очевидно, могли бы создать более интересный ландшафт. На каргах с поддержкой более четырех текстур на проход мы можем смешать больше типов поверхностей и ввести больше случайностей в итоговый результат. Больше, как всегда, значит лучше, поэтому эту главу мы закончим знакомством с понятием смешения в буфере кадров, которое будем активно использовать далее в нашей книге. Даже при ограничении в четыре текстуры при каждом проходе мы можем смешать полный набор из четырех типов поверхностей, если перейдем на многопроходный рендеринг. Эти дополнительные проходы понизят общую скорость рендеринга, но резулыаг вполне окупит издержки. В нашей второй демонстрационной программе этой главы мы организуем полный набор из четырех значений смешения и добавим в ландшафт чуть-чуть снега. Для управления процессом перемешивания текстур изображений мы будем использо- вать все четыре канала, входящих в образ смешения, а значит, нам придется искать иной путь введения в ландшафт элементов случайности. К счастью, если мы поделим процесс на два прохода, то в каждом из них освободим место еще для одной текстуры. На первом проходе смешаем текстуры 1 и 2 (в нашем случае это трава и порода), а на втором - доба- вим оставшуюся пару поверхностей (грунт и снежный покров). При каждом проходе это потребует трех текстур (текстура смешения плюс две поверхности), поэтому для своих нужд у нас есть целая свободная ячейка памяти - слот. Новую текстуру можно использовать с различными целями. Можно подумать о созда- нии графической накладки на весь ландшафт. Представьте себе дорогу из желтого кирпича, вьющуюся между холмами. Чтобы ее увидеть, нам понадобится текстура, которая имеет достаточно высокое разрешение и в альфа-канале содержит маску для управления своей видимостью в отдельных частях ландшафта. Подобную текстуру мы могли бы использовать для нанесения на ландшафт тени от облаков. Если бы текстура была заполнена большими участками смягченного шума, то модуляция цветов ландшафта с участием такого изображения произвела бы впечатление облачной тени. Тот же эффект в движении дала бы анимация координат по UV. Пока нас не интересует дорога из желтого кирпича, а к облакам в нашей книге мы вернемся позднее. В нашей второй демо-программе мы введем в эту текстуру четыре отдельные картины распределения шума - по одной, созданной специально для поверх- ности того или иного типа. Это позволит добавить к траве, породе, грунту и снегу тот шум, который лучше подходит к каждому типу поверхности. Пользуясь графическим редактором, мы можем внести в каналы текстуры шумов конкретные дополнительные детали. В случае с травой воспользуемся точечно-пунктирным шаблоном, который отражает вид отдельных травинок. На скалы нанесем более крупный, ямчатый шум. Для грунта и снега будут подобраны такие шумовые каналы, которые подходят именно для этих поверхностей.
МЕТОДЫ ТЕКСТУРИЗАЦИИ 213 В новой реализации пиксельного шейдера версии 1.1 мы выполним два прохода. На первом отобразим грунт и траву, на втором - скалы и снег. При каждом про- ходе для усиления полученного в итоге цвета используем собственные каналы для текстуры смешения и для текстуры шума. Новый код пиксельного шейдера показан в листинге 9.5. Кроме того, его можно найти на прилагаемом компакт-диске в составе программы chapter9_demol, которая также служит иллюстрацией новой функции InitDeviceOb- j ects, используемой для нанесения снега на наш ландшафт. Внешний вид шейдеров немного загадочен, и виной тому прискорбный побочный эффект От применения при написании пиксельных шейдеров версии 1.1 HLSL-кода. Версия 1.1 дает возможность использовать в каждом из шейдеров лишь восемь арифме- тических инструкций. Но даже самый ясный HLSL-код часто выходит за эти рамки, а потому его нельзя скомпилировать. Однако упаковка инструкций в команды, кажущиеся не столь явными, дает HLSL-компилятору чуть больше свободы в создании лучшего кода. В этом состоит маленькая, но удобная хитрость, и она прекрасно работает. К примеру, мы хотим перемножить текстуры смешения и шума, после чего исполь- зовать отдельные каналы полученного произведения в работе с каждой из наших текстур поверхности. Прямолинейный подход был бы примерно таков: //после того как все четыре текстуры прочитаны... //объединим текстуры смешения и шума Float4 rO = t0*t3; // скалярно умножим на vRedMask для выделения // красного канала гО и перемножим с t2 t2= t2* dot(rO, vRedMask); // сделаем то же самое с синим каналом // и текстурой, расположенной в tl tl= tl* dot(rO, vBlueMask); // объединим результаты с диффузней // составляющей цвета вершины return (tl+ t2)* In.vDiffuse; К сожалению, по этому коду компилятор HLSL сгенерирует более восьми инструкций (в версии DirectX 9.0), что сделает невозможным запуск шейдера на устаревшей технике. Замена вхождений гО в тексте кода на адекватное выражение, кажется, снижает остроту этой проблемы, связанной с компилятором, и позволяет создать нужное число инструкций. В итоге не столь очевидные строки текста при трансляции действительно дают меньший объем кода. Этот трюк реализует замена гО на представленное им произведение (t0*t3). t2= t2* dot((t0*t3), vRedMask); // скалярное умножение на маску красного цвета tl= tl* dot((t0*t3), vBlueMask); // скалярное умножение на маску синего цвета return (tl + t2)* In.vDiffuse;
214 Глава 9 Листинг 9.5. Организация еще одной ландшафтной поверхности через переход на многопроходный рендеринг float4 TwoSurfacePassO(VS_OUTPUT11 In) : COLOR { const float4 vCO //маска красного цвета =float4(l.Of,O.Of,O.Of,O.Of); const float4 vCl //маска синего цвета =float4(0.Of,0.Of,1.Of,0.Of); float4 to = tex2D(LinearSampO, In.vTexO ) float4 tl = tex2D(LinearSampl, In.vTexl ) float4 t2 = tex2D(LinearSamp3, In.vTex2 ) float4 t2 = t2* tl= tl'* return t3 = tex2D(LinearSamp5, r dot((t0* t3), vCl); * dot((tO* t3), vCO); (tl+ t2)* In.vDiffuse; In.vTex3 ) } float4 TwoSurfacePassl(VS_OUTPUT11 In) : COLOR { const float4 vCO //маска зеленого цвета =float4(0.Of,1.Of,0.Of,0.Of); const float4 vCl //маска альфа-канала =float4(0.Of,O.Of, O.Of,1.Of); float4 to = tex2D(LinearSampO, In.vTexO float4 tl = tex2D(LinearSamp2 , In.vTexl float4 t2 = tex2D(LinearSamp4, In.vTex2 float4 tl= tl* t2 = t2* return t3 = tex2D(LinearSamp5, dot((tO* t3), vCO); dot ((tO* t3) , vCl) ; (tl+ t2)* In.vDiffuse; In.vTex3 } technique MultiPassTerrain { pass P0 { CULLMODE = CW; ZENABLE = TRUE; ZWRITEENABLE = TRUE; ZFUNC = LESSEQUAL; AlphaBlendEnable = false;
МЕТОДЫ ТЕКСТУРИЗАЦИИ 215 // шейдеры VertexShader = compile vs_l_l VS11(); Pixelshader = compile ps_l_l TwoSurfacePassO() ; } pass Pl { CULLMODE = CW; ZENABLE = TRUE; ZWRITEENABLE = TRUE; ZFUNC = LESSEQUAL; // наложим этот проход // на предыдущий AlphaBlendEnable = true; SrcBlend = one; DestBlend = one; BlendOp = add; // шейдеры VertexShader = compile vs_l_l VS11O; Pixelshader = compile ps_l_l TwoSurfacePassl() ;
Часть III Дальнейшее развитие движка Теперь мы можем приступить к изучению более сложных аспектов рендеринга ланд- шафтов. Мы создали и запустили базовый вариант ландшафта, снабдив его методами для управления геометрией и процедурной текстуризации. В скрытой от зрителя части проекта мы создали очередь, способную эффективно осуществлять многопроходный рен- деринг. Все готово к изложению более сложных идей. В этой, последней части мы интегрируем в движок ряд новых возможностей для качест- венной работы со светом, растительностью и небесной сферой. Для упрощения организации тех или иных эффектов мы будем вынуждены возвращаться назад и расширять свой базовый конвейер рендеринга. Кроме того, мы будем придерживаться требований обратной совмести- мости с вершинными и пиксельными шейдерами версии 1.1. В отдельных случаях это огра- ничит наши возможности, но такова необходимая плата за повсеместную совместимость. В главе 4 «Обзор движка Gaia» мы упомянули тот факт, что наши модели содержат коллекцию объектов cEffectFile, именуемую cRenderMethod. Пока для вывода на экран любого объекта мы пользовались лишь одним методом рендеринга: это был шей- дер, загруженный на выбранном по умолчанию этапе работы каждого cRenderMethod. В этой части мы будем осуществлять многоэтапный рендеринг нашей сцены. На каждом этапе для отображения «вклада» объектов в процесс рендеринга мы будем использовать нужный шейдер из состава cRenderMethod. Управление этапами визуализации берет на себя cGameHost. Как только объекты получают команду на постановку себя в очередь на рендеринг, они опрашивают cGame- Host, чтобы узнать, какой этап процесса рендеринга проходит в данный момент. Затем они отправляют в очередь на рендеринг свои файлы эффектов cEffectFile или пропус- кают рендеринг, если cRenderMethod не содержит элементов для нынешнего этапа отображения. Это, как мы увидим позднее, позволит произвести многоэтапный рендеринг кадра, давая каждому из объектов возможность на каждом этапе воспользоваться отдельным шейдером.
217 Намеченную тему мы начнем обсуждать в главе 10 «Страна высокого неба», где обратим внимание на небеса. Небо и дальний план, хотя они и не так интересны, как методы ланд- шафтной геометрии, играют центральную роль в построении окончательного облика сцены. Чтобы придать этим вещам более занятный характер, мы рассмотрим несколько процедурных методов синтеза облаков, а также достаточно неуместный здесь эффект рассеяния света на объективе, которым пользуется так много игр, проходящих на открытом пространстве. К небу мы вернемся в главе 11 «Рендеринг сцен на открытом пространстве», где изучим систему атмосферного освещения, которую создадим для движка. Ею мы будем пользоваться для организации более реалистичной модели освещения своей сцены. Там, где это возможно, мы добавим простое рельефное текстурирование и тени, которые помогут улучшить само освещение. Именно здесь мы начнем пользоваться многоэтапным рендерингом и собственными шейдерами для каждого отдельного этапа работы. На нашей сцене ярко светит солнце, а потому есть смысл наполнить ее листвой. В главе 12 «3 D-садовник» мы обратимся к нескольким простым способам, позволяющим заполнить среду травой, деревьями и другими растениями. Нам предстоит изучить простые билборды и объемные суррогаты растительности, которые возьмут на себя боль- шую часть работы. Следуя характеру этой книги, ориентированной на процедурный подход, мы продемонстрируем, как программно менять модели растительности с целью удобного расположения их в составе пейзажа. Закончим же книгу мы добавлением простого водного шейдера и превращением нашего мира в остров. Это подразумевает преобразование нашей карты высот в остров и дальнейшее формирование фрагментов меша, которые будут представлять воду. Заключительный этап нашего изучения рендеринга ландшафтов станет завершением книги, но не последним по значимости вопросом среди тех, которые мы в ней обсудили.
Глава 10 СТРАНА ВЫСОКОГО НЕБА До сих пор важнейшей в ландшафтном движке была земная поверхность. Как мы уви- дели в двух предыдущих главах, первостепенную важность с позиций создания реали- стичных изображений имеют различные методы, нацеленные на представление геометричес- ких форм и внешнего вида поверхностей. В этой главе мы обратим свое внимание на небо, чтобы добавить в наш ландшафтный движок чуть-чуть атмосферы (игра слов неслучайна). Для начала мы изучим базовые приемы формального отображения среды, которые позволят окружить наш ландшафт рядом конкретных изображений. Пользуясь получен- ными знаниями, мы рассмотрим программную генерацию отдельных текстур, представ- ляющих небо и облака, и покажем, как их совместно использовать для создания убеди- тельной, выразительной сцены. «Небесная оболочка» Небесная оболочка (sky box) - это достойный особого уважения основополагающий эле- мент атмосферного рендеринга. Применяемая почти в каждом ландшафтном движке и в каждой игре на открытом (outdoor1) пространстве, небесная оболочка может с большим успехом предоставлять полный обзор вокруг камеры на 360 градусов. Приступая к изучению рендеринга небесной сферы, начнем с реализации простой небесной оболочки. 1 Игры на открытом (outdoor) пространстве - такие, где игровой процесс не ограничен стенами (Serius Sam, Pro Hunter), в противоположность играм в закрытом (indoor) пространстве (DOOM, Quake). Соответственно применяются различные методики визуализации для каждого типа таких игр. - Примеч. науч. ред.
СТРАНА ВЫСОКОГО НЕБА 219 Небесная оболочка - обычный параллелепипед, построенный вокруг камеры так, что последняя находится непосредственно в его центре. Их взаимное расположение показано на рис. 10.1. Создать полную сцену вокруг точки наблюдения камеры можно, отображая текстуры па внутренние поверхности всех граней параллелепипеда. Организация небес- ной оболочки такого рода потребует простого набора: восьми вершин (одной на каждый угол), а также шести текстур. Эти текстуры будут передавать вид каждой грани небесной оболочки с точки зрения камеры. Изображение на верхней грани передает вид над камерой, на нижней грани - под камерой и т. д. Чтобы визуализировать простую небесную оболочку, мы, пользуясь подходящей текстурой, можем произвести рендеринг каждой из ее граней. Когда же все грани будут показаны, экран заполнит панорама ландшафта, расположенного вокруг' камеры. При этом большое внимание нужно, разумеется, уделить бесшовному характеру всех шести отдель- ных текстурных карт, а также их аккуратному построению для передачи надлежащего вида. Создание кубических карт среды делает весь процесс рендеринга простым делом, тре- бующим единственного прохода. Кубические карты среды можно считать заранее созданной небесной оболочкой, принимающей форму текстур. Действительно, такая карта включает в себя шесть текстур, по одной на каждую сторону куба. Поддержка кубических карт со стороны DirectX реализована в виде объекта !Direct3DCubeTexture9. Этот’ класс описывает набор текстур и фактически дает возможность сгруппировать шесть текстур в один единый объект. На рис. 10.1 показано, как эти текстуры расставлены по сторонам куба. ГРАНЬ 2 (+У) Lx— ГРАНЬ 1 t ГРАНЬ 4 t м ГРАНЬ 0 (+*) ГРАНЬ 5 t Lz— Lx- Lz_> L_x^ ГРАНЬ 3 (-у) I Ni Lx- Рис. 10.1. Шесть граней кубической карты. На каждой из граней куба приведено значение индекса и задана система координат
22G Глава 10 ( Чтобы произвести отображение кубической карты среды, воспользуемся ЗЕ)-ко- 4 -—' ординатами наших текстур. В отличие от традиционных текстурных «<.арт коорди- наты кубической карты суть вектор взгляда, направленный из центра куба. Этот вектор используется видеокартой для отыскания той стороны куба, с которой мы возьмем итоговое значение цвета. На рис. 10.2 показан пример куба с точкой наблюдения в его центре. Будучи нормированным направлением взгляда, представленный вектор прямо отображается в набор 3D-координат текстуры, которые послужат для выборки цвета на кубической карте. Рис. 10.2. Углы наблюдения, найденные на единичной кубической карте, прямо отображаются в ЗО-координаты текстур этой же карты. В данном случае вектор отображается в точку (х, у), изображенную на грани 4. Ею является положит нльная z-грань кубической карты Большая часть коммерческих пакетов 3 D-рендеринга содержит методы создания текстур, необходимых для построения кубических карт среды. Далее при помоши DirectX Texture Tool - программного средства, входящего в DirectX SDK, г- эти отдельные текстуры можно собрать в единый dds-файл текстур кубической карты. Сделать первые шаги вам помогут несколько файлов кубических карт, расположенных на компакт-диске в папке source\bin\media\textures. Входящий в движок класс cTexture готов описывать как традиционные, так и кубичес- кие текстурные карты, делая их применение прозрачным для остальной части движка. Беглый взгляд на его интерфейс дает возможность обнаружить несколько дополнительных методов работы с кубическими картами. Двумя основными функциями здесь являются сге- ateCubeTexture и uploadCubeFace. Первая позволяет создать кубическую карту с нуля, вторая предоетавляс г средства загрузки и нанесения отдельных объектов сГшадь на шесть
СТРАНА ВЫСОКОГО НЕБА 221 граней кубической карты. В ходе загрузки dds-файла класс cTexture определит, содержит данный dds-файл традиционную или кубическую карту текстур, и выполнит для каждой из них нужную подпрограмму создания. Помимо поддержки кубических текстур нам нужен класс для манипуляций небесной оболочкой как таковой. В составе Gaia имеется такой класс, уместно названный cSkyModel. Он решает простую задачу загрузки геометрии небесной оболочки (обычного единичного куба) и ее отображения по запросу. Заметим, что форма' оболочки не всегда имеет про- порциональный масштаб. Реально она представляет собой всего лишь параллелепипед, длина каждой стороны которого равна двум мировым единицам. Коль скоро наш рен- деринг небесной оболочки - это сценарий особого рода, мы создадим отдельную матрицу для работы с камерой в «единичном пространстве». Фактически это матрица вида через объектив камеры, центр которой лежит в начале координат и которая обозревает расстоя- ния в диапазоне от 0,01 до 2,0 мировых единиц. Объект cCamera уже оснащен всем необходимым для построения матрицы вида в единичном пространстве. Матрица вида есть комбинация поля зрения из мировой матрицы по направлению камеры и улучшенной проективной матрицы. Улучшенная проективная матрица наследует угол обзора и формат камеры, но ограничивает ближнюю и дальнюю плоскость аналогичными плоскостями в нашем единичном пространстве. Внутри класса эти две матрицы объединяются в так называемую матрицу небесной обо- лочки. Именно ею мы и будем пользоваться при рендеринге самой оболочки. Листинг 10.1 описывает создание матрицы вида оболочки в классе cCamera . ЛИСТИНГ 10.1. Создание матрицы вида небесной оболочки в классе cCamera void cCamera::setProjParams( float fFOV, float fAspect, float fNearPlane, float fFarPlane ) // запомним атрибуты // проективной матрицы m_fFOV = fFOV; m_fAspect - fAspect; m_fNearPlane = fNearPlane; m_fFarPlane = fFarPlane; // построим обычную проективную матрицу D3DXMatrixPerspectiveFovLH( &m_matProj, fFOV, fAspect,
222 Глава К fNearPlane, fFarPlane ); // создадим в единичном пространстве // матрицу геометрии оболочки. Она // станет гарантией того, что ближняя // и дальняя плоскости содержат // единичное пространство вокруг камеры D3DXMatrixPerspectiveFovLH( &m_matUnitProj, fFOV, fAspect, O.Olf, 2 . Of ) ; } void cCamera::recalcMatrices() { // небесная оболочка использует // обратную мировую матрицу камеры // (матрицу поля зрения камеры) без // какой-либо информации о переносе. m_matSkyBox = inverseWorldMatrixO; m__matSkyBox._41 = O.Of; m_matSkyBox._42 = O.Of; m_matSkyBox._43 = O.Of; . // для получения матрицы вида небесной // оболочки она объединяется с проективной // матрицей, заданной в единичном пространстве D3DXMatrixMultiply( &m_matSkyBox, &mj_matSkyBox, &m_matUnitProj) ; // остальной код инициализации камеры... } Сама небесная оболочка заключена в простой класс cSkyModel. Он содержит гео- метрию этой оболочки (простой куб, загруженный в память с диска) и указатели на объекты cEffectFile и cSurfaceMaterial, которые используются для ее рисования. В отличие от геометрии обычной сцены не будем ставить нашу небесную оболочку в очередь на рен- деринг. Обычно небесные оболочки отображаются в решающие моменты процесса рисова- ния сцены, поэтому класс cSkyModel мы обеспечим возможностью отображения по запро- су. В большинстве случаев вывод небесной оболочки предшествует выводу любой другой геометрии. Небесная оболочка всегда заполняет экран целиком, поэтому нередко служит
СТРАНА ВЫСОКОГО НЕБА 223 для перезаписи любой текущей информации о глубине или цвете экрана перед рендерин- гом. Отобразив небесную оболочку в самом начале, программа может фактически пропус- тить все операции по очистке экрана. Однако на деле по мере усложнения небесной оболочки выгодным может стать ее вывод в конце. Так происходит, когда для отображения оболочки используется сложный пиксельный шейдер. В подобных случаях стандартная очистка экрана и рендеринг гео- метрии сцены до вывода оболочки станут гарантией того, что скрытые ландшафтом фраг- менты оболочки не будут отображаться. Решение о моменте визуализации небесной обо- лочки требует проведения тестов для выбора наилучшего времени для этого действия. Как только небесная оболочка построена, сам рендеринг становится очень -------у простым - особенно потому, что мы уже создали классы, которые выполнят большую часть работы (cEffectFile и cSurfaceMaterial). Чтобы увидеть процесс настройки и рендеринга небесной оболочки в полном объеме, смотрите файл main. срр про- екта chapterlo_demoO на компакт-диске. Простой файл эффектов, используемый для рен- деринга оболочки, приведен в листинге 10.2. В этом шейдере координаты небесной обо- лочки просто преобразуются в ЗВ-координаты текстур, которые и служат для обращения к текстуре на кубической карте. По форме наш куб - обычный параллелепипед со сторонами в две единицы, центрированный относительно к началу координат, поэтому мы знаем, что его вершины напрямую отображаются в ЗВ-координаты текстур в диапазоне от -1,0 до 1,0. ЛИСТИНГ 10.2. Файл эффектов simple skybox. fx // // Простой шейдер небесной оболочки // // преобразования fIoat4x4 mWorldViewProj: WORLDVIEWPROJECTION; // кубическая карта texture texO : TEXTURE; struct VS_INPUT { float4 Pos : POSITION; }; struct VS_OUTPUT { float4 Pos : POSITION; float4 vTexO: TEXCOORDO; }; lUTPUT VS(const VS_INPUT v) {
224 Глава 10 ______________________________________________________________ - - ----- VS_OUTPUT Out = (VS_OUTPUT)0; // преобразуем вершину float4 pos = mul(v.Pos, mWorldViewProj); // выведем ее при z, равном w, для принудительного // переноса на максимально возможное расстояние // по оси z в буфере глубины Out.Pos = pos.xyww; // для перевода вершин в // ЗО-координаты текстур // необходима небольшая настройка Out.vTexO = v.Pos.yzxw; return Out; } // координаты кубической карты не должны оборачиваться sampler LinearSampO = sampler_state { texture = <texO>; AddressU = clamp; AddressV = clamp; AddressiW = clamp; MIPFILTER = LINEAR; MINFILTER = LINEAR; MAGFILTER = LINEAR; }; float4 CubeMap(VS_OUTPUT In) : COLOR { // вернем текстуру кубической карты return tex3D(LinearSampO, In.vTexO ); } technique BasicCubeMap { pass PO { // без выделения CULLMODE = NONE; // не проверяем z, просто // переписываем это' значение ZENABLE = TRUE; ZWRITEENABLE = TRUE; ZFUNC = always;
СТРАНА ВЫСОКОГО НЕБА 22Е // также очищаем образец (stencil) StencilEnable = true; StencilFunc = always; StencilPass = replace; StencilRef = 0; AlphaBlendEnable = false; // шейдеры VertexShader = compile vs_l_l VS ( ) ; PixelShader = compile ps_l_l CubeMap(); } } Небесный купол Основной недостаток приемов, основанных на небесной оболочке, заключается в слож- ности анимации текстур. Будучи параллелепипедом с рисунками, нанесенными на каждую грань, такая оболочка становится причиной проблем при попытке анимации чего-то похо- жего на движение облаков по небу. Это связано с характером кубической карты, которую мы используем, и тем фактом, что наша геометрия имеет лишь восемь главных вершин, между которыми мы можем располагать текстуры. В действительности, чтобы представить небо, мы можем выбрать любой тип геометрии и по-прежнему пользоваться текстурами кубиче- ских карт. Все, от чего мы откажемся, - возможность прямого взаимного отображения вершинных и текстурных координат при выборке элементов кубической карты. В качестве альтернативы небесной оболочке прямоугольной формы мы перейдем к небесному куполу. Как следует из названия, это купол, размещенный над камерой. Правда, он не является полусферой в истинном смысле слова. Чтобы улучшить анимацию 2Э-текстур, связанную с движением облаков по небу, мы будем пользоваться сплющенной полусферой. Этот тип геометрии показан на рис. 10.3. Сжатие купола означает меньшие искажения при наложении двухмерных текстурных карт. К тому же, как показывает рис. 10.3, оно пре- дотвращает появление на куполе ребер вертикальной ориентации. Если бы в качестве купола мы избрали настоящую полусферу, то нам казалось бы, что бегущие облака опускаются прямо за края купола, а не исчезают вдали. Единственное отличие, которое переход к куполу вносит в процесс отображения, состо- ит в том, что нам придется вернуться к очистке экрана при выводе каждого кадра и осуще- ствлять шаг дополнительного нормирования в нашем вершинном шейдере. Очистка снова необходима в силу отсутствия у купола нижней стороны; нормировать же вектор от камеры до каждой вершины для получения координат кубической карты мы вынуждены потому, что купол имеет вершины, лежащие от камеры на расстоянии, отличном от единицы длины. 8- 1839
226 Глава 10 Рис. 10.3. Геометрия небесного купола: вид сверху и сбоку Согласно коду cSkyModel имеет все, что нужно для загрузки модели купола по запросу. Помимо этого изменения в геометрии, иных различий между работой с параллелепипедом и куполом для передачи небосвода не буцет. Вершинный шейдер, использующий небесный купол, нужно, как уже говорилось, пополнить операцией, нормирующей вектор для получе- ния координат кубической карты, что и показано в листинге 10.3. ЛИСТИНГ 10.3. Вершинный шейдер simple_skydome. £х. В остальном файл эффектов идентичен листингу 10.2 VS-.OUTPUT VS (const VS_INPUT V) { VS_OUTPUT Out = (VS-OUTPUT)0; // преобразуем вершину float4 pos = muKv.Pos, mWorldViewProj); // выведем ее при z, равном w, для принудительного // переноса на максимально возможное расстояние //по оси z в буфере глубины Out.Pos = pos.xyww; // для перевода вершин // в 3D-координаты текстур //и нормировки необходима // небольшая настройка
СТРАНА ВЫСОКОГО НЕБА 227 Out.vTexO.xyz = normalize(v.Pos.yzx); Out.vTexO.w=l.Of; return Out; Анимация облаков Прежде чем познакомиться еще с одной демонстрационной программой, которая покажет небесный купол в работе, мы сделаем небосвод чуть более привлекательным, реализовав слой анимированных облаков. Для анимации неба, покрытого облаками, объе- диним множество карт шумов, движущихся по куполу неба с различной скоростью. Смесь этих карт шума будет постоянно менять форму проплывающих облаков. Добавим в пиксельный шейдер дополнительную текстуру. Это простая текстура шума, в каждом цветовом канале содержащая шум, заданный оттенками серого. В пик- сельном шейдере мы дважды обратимся к этой текстуре, считав из нее два независимых друг от друга значения шума. В текстуре каналы шумов делятся на частоты двух видов. Красный, зеленый и синий каналы содержат сами изображения облаков - простое распре- деление шума, которое описывает легкие облака. В альфа-канале мы разместим иную час- тоту шума и будем пользоваться ею для изменения формы облаков при движении. При- меры каждого из каналов текстур приведены на рис. 10А Анимация, реализуемая этими двумя слоями шумов, является простой модуляцией их обоих. Если координаты отображений текстур мы анимируем со слегка разными скоро- стями, то перекрывающие друг друга текстуры шума приведут к образованию облаков, меняющих свою форму при совместной их модуляции. По сути эти текстуры можно рас- сматривать как маски, объединяемые на фоне неба. Там, где белые области обеих масок пере- крывают друг друга, и появляются облака. Форма же перекрытия областей масок все время меняется оттого, что обе текстуры шумов пересекают небосвод с различными скоростями. Рис. 10.4. Два изображения, вкупе используемых для анимации облаков путем совместной их модуляции
228 Глава 10 При отображении карты облачного покрова мы позаботимся о том, чтобы скрыть облака, расположенные вокруг горизонта. По мере приближения облаков к краю купола они кажутся уходящими к горизонту вниз. Так как текстуры облаков проецируются на наш купол, то при отсутствии маскировки они появятся на верхней и нижней его половине. Чтобы справиться с этим, добавим к кубической карте горизонта альфа-канал. В примере кубической карты из демонстрационной программы он обесцвечивает облака, как только они приближаются к находящимся на горизонте изображениям гор. Тем самым, достигнув линии горизонта, облака исчезают, а это предотвращает их появление поверх лежащих вдали фрагментов ландшафта. Эта работа осуществляется в пиксельном шейдере так, как показывает листинг " Ю.4. Карта фона (простая кубическая карта горной цепи) и слои облаков сэмплируются, по1ле чего объединяются в итоговый вариант небесного купола. Для получения облаков оба облачных слоя совместно модулируются и маскируются альфа- каналом фона. Результат этих действий затем суммируется с фоновым образом, что и дает окончательный итог вычислений. Пример изображения неба из находящейся на прилагае- мом компакт-диске программы chapterlO_demol. ехе показан на рис. 10.5. Рис. 10.5. Пример изображения, полученного методом рендеринга облаков
СТРАНА ВЫСОКОГО НЕБА ЛИСТИНГ 10.4. Пиксельный шейдер simple_skydome . fx, используемый для объединения карт облаков с изображением фона float4 CloudShader(VS_OUTPUT In) : COLOR { // сэмплируем кубическую карту и облака float4 background= tex3D(LinearSampO, In.vTexO ); float4 cloud0= tex2D(LinearSampl, In.vTexl ); float4 cloudl= tex2D(LinearSampl, In.vTex2 ); // произведем совместную модуляцию облаков, // маскируем результат альфа-каналом фона float4 cloud_layer = cloudO * cloudl.a * background.a; // суммируем и вернем результат return background + cloud_layer; } Рассеяние света на объективе Если обычную камеру направить в сторону источника света, вторичные отражения лучей в ее оптике могут создать эффект светорассеяния на объективе. Его проявления - полупрозрачные фантомные образы и яркие пятна, которые образуются на снятом камерой изображении. В реальной фотографии это часто считают неприятным явлением. В видеоиграх такой эффект служит деталью, нередко используемой для имитации сним- ков, сделанных на открытом пространстве. Но даже несмотря на то, что эффектом рассея- ния света на объективе часто злоупотребляют, его вклад в придание реалистичности соз- данным изображениям нельзя сбрасывать со счетов. В практике фотографии светорассеяние на объективе возникает, когда яркие лучи источника света падают прямо на линзу камеры. В этих случаях противоотражательное покрытие линз не справляется с ярким светом, и тот начинает свое «блуждание» по объек- тиву. Вторичные отражения, вызванные источником света, обнаруживают себя в виде ореола и геометрическик образов. Впрочем, нечто столь же хаотичное можно легко смоде- лировать и в 2Б-пространстве. Не все случаи светорассеяния ведут себя одинаково, однако мы убеждены в том, что при создании правдоподобного эффекта имеем право на широкое обобщение. В большинстве ситуаций положение линз в камере таково, что создает эффект светорассеяния как набор бли- ков, расположенных по прямой линии. Эта линия, проходящая через центр линзы, соединяет точку проекции источника света с точкой его инверсного положения. В нашем движке это рав- носильно линии, проведенной из места расположения источника света на экране в его центр. Длина самой линии по обе стороны от центра экрана, как видно на рис. 10.6, одна и та же.
230 Глава 10 Нам остается определить размер и положение бликов, которые образуются под дейст- вием эффекта светорассеяния. Первый блик построить проще всего. Переизбыток блуж- дающих по камере лучей света создает точно в центре источника света (находящегося в пространстве экрана) ореол, вспышку или пятно ярко-белого света. На языке фотогра- фии это явление сродни переэкспонированию пленки в точке, на которую проецируется источник. Вне зависимости от его цвета ореол часто имеет чрезмерную яркость и стано- вится белым, хотя вдоль каждого из образованных им лучей он слабеет и принимает истинный цвет источника. Остальные блики зависят от расстояния между линзами камеры, количества имеющихся в ней апертур и всевозможных эффектов призматического характера, возникающих на краях каждой линзы. Свою роль играет и тип пленки, определяющий цветовой сдвиг рассеяния света на объективе. Кроме того, видеокамеры обладают своим собственным, характерным для них светорассеянием, часто образуя красные или синие горизонтальные полосы. В итоге каждая камера имеет бессчетное число возможных причин рассеивать свет, и пытаться реали- стично смоделировать их было бы ненужным бременем, возложенным на наш движок. Как выяснилось, апеллировать к истинным причинам светорассеяния мы не можем. Вместо попытки смоделировать реальные многократные отражения лучей света при про- хождении через линзы, мы видим, что можем построить правдоподобную модель рассея- ния света, пользуясь абсолютно понятными наблюдениями. По этой причине мы не будем сосредоточиваться на реальной оптической стороне дела, а обратим внимание на оконча- тельный результат. Для получения эффекта рассеяния света на объективе мы будем пользо-
СТРАНА ВЫСОКОГО НЕБА 231 ваться заранее заданным набором бликов и форм, и это позволит нам просто выбрать место- положение и цвет источника света, которые являются параметрами указанного эффекта. С Сначала мы создадим ряд текстур с бликами часто встречающихся артефактов -------светорассеяния. Это полупрозрачные изображения, использующие альфа-канал для управления своей непрозрачностью на экране. Эффект рассеяния света возникает непо- средственно на экране, поэтому для получения окончательного изображения мы можем просто смешать эти блики со своей сценой. К числу текстур светорассеяния относятся вспышки, при- нимающие форму звезд, кругов и восьмиугольников. Эти текстуры вы можете найти на ком- пакт-диске в файле source\bin\media\textures\lens_f lare . dds (см. рис. 10.7). Чтобы упорядочить эти блики разных размеров, воспользуемся жестко закодированной таблицей с информацией об относительных размерах и положении. Строки этой таблицы содержат размер каждого блика, приведенный к яркости источника света, и положение вдоль вектора светорассеяния. Принудительное сохранение относительного местоположения каж- дого блика при удлинении вектора вытягивает блики по направлению самого вектора и соз- дает реалистичное движение эффекта рассеяния света на объективе. Сам рендеринг является делом обычного позиционирования текстур на билборды2 с учетом информации о положе- нии и размере. Если отображение на экран носит характер аддитивных наложений со смеше- нием альфа-каналов, вы получите яркий и впечатляющий результат. В систему освещения в составе движка добавим простой класс для обработки собы- тий, связанных с рассеянием света, на основе положения солнца. Этот класс cLensFlare будет рассчитывать в экранных координатах, в каком именно месте экрана источник света находится в каждом кадре. Когда центр источника света окажется в пределах границ экра- на, данный класс построит и отобразит блики, вызванные светорассеянием. Как и в случае с небесной оболочкой, нам придется управлять тем, когда это произойдет. Фантомные образы при светорассеянии должны быть последними из элементов, отображаемых на экран, а для достижения их согласованного смешения вывод должен производиться в одном и том же порядке. В результате вызовы, инициирующие рендеринг cLensFlare, нужно использовать лишь после отображения самой сцены. Нашим серьезным упущением в реализации cLensFlare является проблема с окклюзией. Если солнце заходит за тучу или ветви деревьев, лучи света не достигают линз камеры и эффекты рассеяния сходят на нет. В нашем движке нет средств обнаружить все возможные объекты, не пропускающие солнечный свет. Однако, учитывая угол, под которым видно солнце, мы можем высказать ряд элементарных предположений и «отключить» рассеяние света после захода светила. Реальный тест на окклюзию потре- бовал бы восставить луч от камеры до источника света и проверить наличие пересечений, 2 Билборд (billboard) - полигон, всегда развернутый «лицом» (нормалью) к камере. - Примеч. науч. ред.
232 Глава 10 которые скрывают свет.. Но даже при выполнении таких тестов те процедурные методы создания слоев облачного покрова, которые мы используем, усложняют проверки на наличие преград, какими являются облака. Рис. 10.7. Примеры изображений, используемых для получения эффекта светорассеяния на объективе Одно из потенциальных решений состоит в том, чтобы произвести рендеринг сцены во внеэкранный буфер, используюший узкое поле зрения камеры, сфокусированной на солнце. При этом солнце будет представлено как яркий шар белого цвета, а все другие объекты - как черные силуэты. Результатом этого действия станет окончательное изобра- жение, которое содержит центральный пиксел белого цвета там, где солнце видно, и черного - там, где его не видно. Если бы мы пользовались им как текстурой при рен- деринге рассеяния света на объективе, то могли бы выбрать этот центральный тексел и, пользуясь его значением, управлять видимостью бликов рассеяния света. В случае, если солнце видно, такой белый тексел позволяет отображать эффект светорассеяния. По мере же затухания гексела до черного эффект рассеяния света также ослабевает. , Детали реализации рендеринга со светорассеянием и класса cLensFlare можно найти на прилагаемом компакт-диске. Основы этой техники в действии показывает демонстрационное приложение chapterl0_demo2.
Глава 11 РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ На этой стадии разработки движка мы начнем использовать ряд возможностей, которые до сих пор игнорировали. В главе 4 «Обзор движка Gaia» мы упомянули тот факт, что наши модели содержат коллекцию объектов cEffectFile (заменяющих файлы эффектов Direct3D). Но даже если у нас было Достаточно места для хранения множества наборов HLSL-шейдеров для каждой модели, мы ограничивали себя лишь одним. Это позволило создать и запустить базовый движок, не слишком беспокоясь о текстуриро- вании рельефа (bump mapping1), освещении и прочих дополнительных элементах. В этой главе для достижения большего эффекта мы начнем применять дополнительные шейдеры HLSL. К моменту окончания работы мы будем иметь более качественную модель освещения, а также текстуры рельефа, которые улучшат вид наших объектов и придадут сцене законченный вид. Каждое из этих дополнений будет происходить в рамках отдель- ного этапа процесса рендеринга, в изоляции от других. Это позволит нам, пользуясь при необходимости нестандартными шейдерами в классе cRenderMethod каждого из объек- тов, всякий раз сосредоточивать свое внимание на применении лишь одного этапа. Многоэтапный подход Многоэтапный подход к рендерингу - это лишь способ сократить общий объем рабо- ты, поделив его на дискретные части. Так, в этой главе мы добавим в наш движок освеще- ние, тени и текстуры рельефа. Каждый из элементов будет единственным в своем роде этапом рендеринга, а потому мы сможем отображать их независимо друг от друга. На первом этапе мы визуализируем все рельефные текстуры объекта, на втором - добавим 1 Рельефное текстурирование (bump mapping) - способ имитации шероховатости, неровности поверхности, различных мелких деталей (царапин, заклепок и т. д.) - Примеч науч. ред.
234 Глава 11 тени, а на третьем - наложим их на итоговую модель освещения. Причины выбора такого йорядка рендеринга станут яснее при чтении этой главы. Организация работы в несколько этапов поможет нам реализовать такие возможности, которые, как правило, имеет не каждая видеокарта. Устаревшие видеокарты, ограничен- ные в способности мультитекстуризации, попросту слабо оснащены для того, чтобы за один проход пытаться скомпоновать текстуры, наложить текстуры рельефа и отобра- зить эффекты атмосферного освещения. Чтобы реализовать такие возможности, и необхо- дим многоэтапный подход. В нем результаты всех этапов объединяются и формируют окончательное изображение. У нас есть два основных варианта осуществления итоговой компоновки этапов рен- деринга. Мы можем отобразить результаты каждого из этапов в отдельную область, после чего использовать последний этап для загрузки каждого изображения как текстуры. Этот этап будет ответствен за объединение изображений и получение на экране окончательного результата. Минусом этого метода является то, что каждому этапу необходим свой «при- емник», который также должен иметь совместимый формат, дающий возможность использовать его как текстуру на последнем этапе. Кроме того, для поддержания приемле- мого соотношения числа пикселов в текстурах и на экране мы должны гарантировать, что «приемники» при рендеринге имеют аналогичный или больший размер, чем итоговое изо- бражение. В полноэкранных режимах монитора с высоким разрешением на это может потребоваться солидный объем видеопамяти. Второй путь - послойно накладывать каждый этап непосредственно на кадровый буфер. Здесь для непрерывного добавления выходных данных каждого этапа в итоговое изображение служат операции смешения альфа-каналов. Альфа-смешение слоев, полученных при рендеринге, сужает репертуар эффектов, которые можно производить, пользуясь этой методикой, однако оно гораздо проще в работе и не требует дополнитель- ной видеопамяти для областей-«приемников» результата. Именно этот подход мы будем использовать в данном разделе книги. Будучи не столь гибким, как компоновка отдельно созданных изображений, он все равно может использоваться для построения высококаче- ственных изображений с теми эффектами, которые мы планируем получить. По ходу обсуждения каждого из этапов, с которыми мы познакомимся в этой главе, мы будем показывать, как с помощью альфа-смешения эти этапы можно накладывать на кадровый буфер. Данная информация будет полезна и читателям, желающим испробо- вать при реализации последнего этапа рендеринга подход к компоновке на основе мно- жества зон-«приемников». Как и во всей книге, мы стремимся придерживаться решения, которое соответствует возможностям большего количества видеокарт. Если вы знаете, что ваша конкретная видеокарта способна на большее, мы определенно поддержим вас в же- лании поэкспериментировать с подходом, где участвует много «приемников».
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 235 Как обычно, в качестве центрального источника информации мы будем использовать объект cGameHost. Для управления этапами отображения объект cGameHost поддержи- вает индекс текущих этапов рендеринга. Чтобы начать новый этап, нам нужно лишь уве- домить cGameHost о новом этапе работы и произвести рендеринг объектов в поле зрения камеры. Поскольку эти объекты сами вносят себя в очередь на рендеринг, они запросят у cGameHost текущий этап процесса рендеринга и передадут нужные шейдеры внутри элемента очереди. Фактически объекты выполняют эту работу совершенно самостоя- тельно, a cGameHost просто сообщает о том, что объекты должны использовать свои шейдеры по умолчанию. Чтобы понять суть многоэтапного подхода, мы проанализируем каждый этап в порядке его выполнения. Начнем с простого этапа вывода цвета окружающей среды, после чего наложим на нее текстуру рельефа. На последнем этапе обсудим робастную модель наруж- ного освещения, которая для большей реалистичности использует аппроксимацию света, проходящего через атмосферу Земли. Это дает нам полный список этапов нашей демон- страции: окружающая среда, текстуры рельефа и освещение. Результаты каждого из этапов послойно помещаются в буфер кадров для получения окончательного изображения. Рассеянный свет Первый этап работы самый простой. На нем мы скорректируем карты покрывающих объекты текстур обычным значением цвета, который имеет окружающая среда. Это цве- товое значение характеризует количество рассеянного света на нашей сцене. Такой свет заполняет буфер кадра множеством базовых значений, заносимых в каждый пиксел в составе сцены. Позднее, когда мы произведем рендеринг освещения каждой модели, мы сложим результаты освещения с этими базовыми цветами. Кроме того, воспользуемся простотой этого этапа для установки буфера глубины. Проход, связанный с рассеянным светом, осуществляется достаточно быстро, что делает его идеальной кандидатурой на место записи значений глубины в z-буфер. Последующие, более сложные по своей природе этапы могут быть запрограммированы на применение значений z-буфера, записанных в него в начале работы. Дальнейшая запись в z-буфер ока- жется не нужна, а это поможет увеличить эффективность более поздних этапов. Пример файла эффектов, который служит для отображения вклада рассеянного света в ландшафт, показан в листинге 11.1. Карты текстур в этом шейдере объединяются при помощи метода .смешивания, который мы описали выше. Для получения финального базо- вого цвета итоговый цвет будет умножен на постоянную рассеянную составляющую оттенка.
236 Глава 11 ЛИСТИНГ 11.1. Пример HLSL-шейдера для записи значений в z-буфер и установки базовых цветов каждого пиксела при помощи простого рассеянного освещения // // Простой шейдер ландшафта, // использующий рассеянный свет // // преобразования float4x4 mViewProj: VIEWPROJECTION; // смещения участков ландшафта float4 posOffset : posScaleOffset = {1.0, 1.0, O.Of, O.Of}; float4 texOffset : uvScaleOffset = {1.0, 1.0, O.Of, O.Of}; // используемые карты текстур texture texO : TEXTURE; // смешение texture texl : TEXTURE; // поверхность 0 texture tex2 : TEXTURE; // поверхность 1 texture tex3 : TEXTURE; // поверхность 2 // цвет рассеянного освещения float4 ambient_light = {0.3f,0.3f,0.6f,0.Ob- struct VS_INPUT { float2 Pos : POSITION0; float2 UV : TEXCOORDO; float ZPos : POSITION1; }; struct VS_OUTPUT { float4 Pos : POSITION; float2 vTexO : TEXCOORDO; float2 vTexl : TEXCOORD1; float2 vTex2 : TEXCOORD2; float2 vTex3 : TEXCOORD3; } ; VS_OUTPUT VS(const VS-INPUT v) { VS_OUTPUT Out = (VS_OUTPUT)0; float4 combinedPos = float4( v. Pos.x,
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 23 V.Pos.у, V.ZPos, 1) ; combinedPos.ху += posOffset.zw; Out.Pos = mu1(combinedPos, mViewProj); Out.vTexO = (v.UV+texOffset.zw)*texOffset. xy; Out.vTexl = v.UV; 0ut.vTex2 = v.UV; 0ut.vTex3 = v.UV; return Out; } sampler LinearSampO = sampler_state { texture = <texO>; AddressU = clamp; AddressV = clamp; AddressW = clamp; MIPFILTER = LINEAR; MINFILTER = LINEAR; MAGFILTER = LINEAR; }; sampler LinearSampl = sampler_state { texture = <texl>; AddressU = wrap; AddressV = wrap; AddressW = wrap; MIPFILTER = LINEAR; MINFILTER = LINEAR; MAGFILTER = LINEAR; } ; sampler LinearSamp2 = sampler_state { texture = <tex2>; AddressU = wrap; AddressV = wrap; AddressW = wrap; MIPFILTER = LINEAR MINFILTER = LINEAR MAGFILTER = LINEAR
238 Глава 11 }; sampler LinearSampl = sampler_state { texture = <tex3>; AddressU = wrap; AddressV = wrap; AddressW = wrap; MIPFILTER = LINEAR; MINFILTER = LINEAR; MAGFILTER = LINEAR; }; float4 PS(VS_OUTPUT In) : COLOR { // сэмплируем все четыре текстуры float4 BlendControler = tex2D(LinearSampO, In.vTexO ); float4 texColorO = tex2D(LinearSampl, In.vTexl ); float4 texColorl = tex2D(LinearSampl, In.vTex2 ); float4 texColor2 = tex2D(LinearSampl, In.vTexl ); // определим вклад каждой поверхности при смешивании float4 ColorO = (texColorO * BlendControler.г); float4 Colorl = (texColorl * BlendControler.g); float4 Color2 = (texColor2 * BlendControler.b); // суммируем полученные цвета и // умножим на цвет рассеянного // освещения return (ColorO + Colorl + Color2) *BlendControler.а * ambient_light; } // // Этот прием реализации выводит цвет // рассеянного освещения, попутно заполняя // z-буфер значениями глубины // technique AmbientTerrainShader { 'pass РО { CULLMODE = CW; ZENABLE = TRUE; ZWRITEENABLE = TRUE; ZFUNC = LESSEQUAL;
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 239 AlphaBlendEnable = false; AlphaTestEnable = false; // шейдеры VertexShader = compile vs_l_l VS () ; PixelShader = compile ps_l_l PS(); } } Отображение неровностей Текстуры рельефа мы до сих пор не использовали. Если видеокарта поддерживает дополнительные текстурные слоты, то отобразить неровности на уровне пикселов сцены можно традиционным образом при обычном проходе в процессе рендеринга. Если же мы ограничим себя возможностями более старых видеокарт, мы должны найти способ внедрить текстуры рельефа как независимый этап визуализации. Поэтому воздержимся от реального попиксельного наложения рельефных текстур и воспользуемся с той же целью приемом альфа-смешения, сделав отображение неровностей вторым этапом рендеринга. Реальное отображение неровностей включает применение текстур рельефа, которые содержат векторы нормалей к поверхностям. Эти нормали заносятся в текстуру так, что значения их х-, у- и z-составляющих количественно соответствуют значениям красного, синего и зеленого цвета. Вывод неровностей осуществляет пиксельный шейдер, который сначала переносит вектор, характеризующий свет, в пространство текстуры вершинного шейдера, а затем рассчитывает скалярное произведение этого вектора с нормалью к поверхности, хранящейся в текстуре рельефа. Итогом этого становится скалярное значе- ние, найденное для каждого пиксела и применяемое для коррекции количества света, попадающего на тот или иной пиксел. Если выходом отображающего неровности прохода рендеринга является скалярное значение, которое служит для управления количеством света в конкретном элементе изо- бражения, мы можем просто визуализировать эти неровности как модификаторы освеще- ния в оттенках серого. В этом смысле темная сторона неровности, созданной по текстуре карты нормалей, образует небольшую тень в итоговом альфа-канале. При выполнении последнего этапа работы с освещением скалярные значения в альфа-канале будут управ- лять тем, сколько света будет подмешано в окончательный вариант сцены. Чтобы отчетливо себе это представить, вообразим, что мы вывели нашу модель, исполь- зуя традиционное попиксельное отображение неровностей и белую как мел диффузную текстуру. Это даст нам изображение модели в оттенках серого цвета, ярко-белое в тех зонах, на которые падает свет, и темно-серое там, куда свет не доходит. Все неровности изображения будут отчетливо видны, создав картину возвышенностей и углублений в виде нюансов серого. Это и есть те самые данные, которые мы добавим в результирующий альфа-канал.
240 Глава 11 На последнем этапе параметры освещения модели рассчитываются без учета норма- лей к поверхностям. В известном смысле мы ставим свет так, будто модель освещается со всех сторон. По мере записи значений цветов в буфер кадра они корректируются значе- ниями из альфа-канала, которые мы уже поместили туда при рендеринге текстуры релье- фа. Это сводит на нет вклад освещения на темных участках модели, приводя к итоговому виду отображений неровных мест. Этот результат не является реальным наложением рельефных текстур путем попиксельного расчета скалярного произведения, однако на деле обеспечивает приемлемую аппроксимацию таких действий. Начальная установка этапа отображения неровностей требует определенного вмеша- тельства в исходный код. Данный этан выводит значения лишь в альфа-канал результата. Это накладывает на нас пару ограничений. Во-первых, итоговый буфер обязан содержать альфа-канал. Во-вторых, мы должны гарантировать, что этап отображения неровностей выдает значения исключительно в этот альфа-канал, оставляя красный, зеленый и синий цветовые каналы итогового буфера без изменений. Соблюсти первое ограничение нетрудно. В ходе перечисления режимов экрана при старте программы каждый из них проходит через виртуальную функцию верификации, которая проводится приложением. В программах, использующих результирующий альфа- канал, мы будем просто отбрасывать все те режимы экрана, которые не имеют, как мини- мум, 8 бит, выделенных для альфа-канала. Реализация этой проверки осуществляется путем перегрузки виртуальной функции ConfirmDevice, предоставляемой классом CD3DApplication из состава DirectX Application Framework. Наш собственный класс cGameHos t является потомком этого базового класса, поэтому мы можем просто перегру- зить функцию Conf irmDevice своего объекта этого класса. В листинге 11.2 приведен пример функции, которая разрешает использовать лишь те экранные режимы, что совмес- тимы с нашим методом вывода рельефных текстур. ЛИСТИНГ 11.2. Отбрасывание экранных режимов, несовместимых с методом отображения неровностей на основе альфа-канала HRESULT cGameHost::ConfirmDevice( D3DCAPS9* pCaps, DWORD behavior, D3DFORMAT display, D3DFORMAT backbuffer) { // потребуем наличия альфа-канала в back-буфере if (backbuffer != D3DFMT_A8R8G8B8) { return E_FAIL; }
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 241 // позволим базовому классу // продолжить верификацию устройства return cGameHost::ConfirmDevice( pCaps, behavior, display, backbuffer ); } Второе ограничение также подконтрольно классу cGameHost. Вспомните: этот класс получает уведомления о начале и завершении каждого этапа рендеринга. Это дает ему возможность активизировать любые состояния процесса рендеринга или выполнять любой собственный код, на котором основана работа того или иного этапа. При наложе- нии текстуры рельефа нам нужно уведомить DirectX о ненадобности отображать выход- ные значения, помещаемые в красный, зеленый или синий цветовой канал результата. Нашим единственным выходом станет значение в альфа-канале, которое затем будет использоваться для ослабления вклада освещения в облик сцены. В классе cGameHost простая смена состояния процесса рендеринга вводится в состав функций уведомления об этапах отображения, чтобы ограничить производимый пиксель- ным шейдером вывод цветов лишь альфа-каналом. Обе функции класса cGameHost, которые и производят подобные смены состояния, показаны в листинге 11.3. ЛИСТИНГ 1 1.3. Ограничение вывода в альфа-канал при рендеринге рельефных текстур void cGameHost::beginRenderStage(uint8 stage) { debug_assert( stage < cEffectFile::k_max_render_stages, "invalid render stage"); // "неверный этап рендеринга" m_activeRenderStage = stage; if (m_activeRenderStage == cEffectFile::k_bumpMapStage) { // на этапе отображения рельефных // текстур мы пишем исключительно // в альфа-канал результата d3dDevice()->SetRenderState( D3DRS_COLORWRITEENABLE, D3DCOLORWRITEENABLE_ALPHA); } } void cGameHost::endRenderStage()
242 Глава 11 { if (m_activeRenderStage = = cEffectFile::k_bumpMapStage) { // восстановим возможность рендеринга // всех цветовых каналов d3dDevice()->SetRenderState( D3DRS_COLORWRITEENABLE, D3DCOLORWRITEENABLE_ALPHA| D3DCOLORWRITfeENABLE_BLUE j D3DCOLORWRITEENABLE_GREEN j D3DCOLORWRITEENABLE_RED); } m_activeRenderStage = 0; } Последняя тема, к которой мы обратимся, - это наложение рельефных текстур на наш процедурный ландшафт. Вспомните из главы 9 «Методы текстуризации», что для созда- ния итогового ландшафта мы смешиваем несколько карт текстур. Каждой из этих текстурных карт представлен особый тип поверхности, такой, как трава, скальная порода, грунт и т. д. Вполне разумно, что для всех типов поверхностей мы предпочтем иметь отдельные текстуры рельефа, а для их объединения в пределах ландшафта воспользуемся той же схемой смешения. На старых видеокартах это создаст небольшую проблему. Вспомните, что отображе- ние неровностей требует применения карт нормалей. Те, по определению, содержат сведе- ния о нормалях к поверхностям, записанные в них как информация о цветах. Если мы объединим эти цветовые каналы при помощи операции смешивания текстур, то результат не обязательно останется нормалью к поверхности. Это породит артефакты, которые будут видны на итоговой текстуре рельефа. Полученные в результате объединения нормали к поверхностям необходимо повторно нормировать, на что старые видеокарты (которые используют пиксельные шейдеры версии 1.x) неспособны. Единственным выходом для таких устаревших устройств является конверсия текстуры смешения в изображснис-маску. Взамен гладкого смешения карт нормалей мы создадим маску, которая позволит участвовать в образовании каждого пиксела только одной из занятых в сме- шении карт. Это даст нам возможность смешивать текстуры рельефа на поверхностях разных типов, не испытывая потребности в повторном нормировании нашего результата. Чтобы конвертировать текстуру смешения, мы лишь проанализируем коэффициенты смешения, хра- нящиеся в канале каждого цвета. Самое большое найденное значение мы установим в 1, а все остальные каналы сбросим в 0. Фактически это преобразует текстуру смешения в пригодную для объединения нормалей к поверхностям маску из четких контуров.
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 243 Функция преобразования convertToBumpMask введена в состав класса cimage. Это тот самый класс, которым мы пользовались, чтобы построить исходную текстуру смеше- ния для собственного ландшафта. Процесс анализа каждого цветового канала текстуры смешения и отыскания самого большого значения кратко описан листингом 11.4. Это значение станет каналом маски, который допустит появление на экране только одной нормали к поверхности в расчете на каждый отображаемый пиксел. ЛИСТИНГ 11.4. Преобразование данных о смешении ландшафтных текстур в информацию о маске из четких контуров void сImage::convertToBumpMask() { int pitch; uint8* pBits = lock(0, &pitch); if (pBits) { uint8* pOut = pBits; for (uintl6 y=0;y<m_height;++y) { for (uintl6 x=0;x<m_width;++x) ( uint32 color; getColorfx, y, color); uint8 r = color&Oxff; uint8 g = (color>8)&0xff; uint8 b = (color>16)&0xff; uint8 a = (color>24)&0xff; // канал может быть только один! if (r>=g && r>=b && r>=a) { r=0xff; g=0; b=0; a=0; } else if (g>=r && g>=b && g>=a) { g=0xff; r=0; b=0; a=0; } else if (b>=r && b>=g && b>=a) { b=0xff; r=0; g=0; a=0; } else {
Глава 11 a=Oxff; r=0; g=0; b=0; } color = (a<<24)+(b<<16)+(g<<8)+r; setColor(x, y, color); } pBits += pitch; } } unlock(); } Листинг 11.5 содержит файл эффектов, используемый для объединения карт нормалей при помощи вновь созданной текстуры-маски. Заметим: чтобы перевести вектор падающего луча в пространство текстуры с целью отображения неровностей, необходимо рассчитать бинормаль и вектор касательной. Наши данные о вершинах ландшафта не содержат такой информации, поэтому для аппроксимации бинормального вектора и вектора касательной мы произведем ряд операций расчета векторного произведения с применением нормали к поверхности. Полученные векторы будут использоваться при переводе вектора света в пространство текстуры для выполнения попиксельных операций скалярного умножения векторов. ЛИСТИНГ 11.5. Два примера функций пиксельных HLSL-шейдеров, используемых для компоновки текстур ландшафтных рельефов. Первая повторно нормирует нормали к поверхностям после смешения, вторая, чтобы препятствовать объединению этих нормалей, пользуется маской смешения // Пиксельный шейдер 1_х не способен нормировать // вектор. Поэтому мы будем полагаться на // тот факт, что для предотвращения вывода // на экран более одной текстуры рельефа была // организована текстура-маска, устраняющая // необходимость повторно нормировать нормали // к поверхностям float4 PS_lx(VS_OUTPUT In) : COLOR { // сэмплируем все четыре текстуры float4 mask = tex2D(LinearSampO, In.vTexO ); float4 texColorO = tex2D(LinearSampl, In.vTexl ); float4 texColorl = tex2D(LinearSamp2, In.vTex2 ); float4 texColor2 = tex2D(LinearSampl, In.vTexl ); // определим вклад каждой поверхности при смешении float4 ColorO = (texColorO * mask.г); float4 Colorl = (texColorl * mask.g); float4 Color2 = (texColorl * mask.b);
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 245 float4 normal = Color0+Colorl+Color2; // рассчитаем попиксельное скалярное // произведение и выведем результат return saturate(dot( (In.vLightVec-0.5f) *2.0f, (normal-0.5f)*2.Of) ) ; } // Пиксельный шейдер 2_x способен нормировать // вектор. В этом случае мы можем воспользоваться // обычной текстурой смешения, а также // нормировать результат до вычисления // скалярного произведения float4 PS_2x(VS_OUTPUT In) : COLOR { // сэмплируем все четыре текстуры float4 mask = tex2D(Line^rSampO, In.vTexO ); float4 texColorO = tex2D(LinearSampl, In.vTexl ); float4 texColorl = tex2D(LinearSamp2, In.vTex2 ); float4 texColor2 = tex2D(LinearSamp3, Tn.vTex3 ); // определим вклад каждой поверхности при смешении float4 ColorO = (texColorO * mask.г); float4 Colorl = (texColorl * mask.g); float4 Color2 = (texColor2 * mask.b); float4 normal = normalize(Color0+Colorl+Color2); // рассчитаем попиксельное скалярное // произведение и выведем результат return saturate(dot( (In.vLightVec-0.5f)*2.Of, (normal-0.5f)*2.Of)); } Аппроксимация наружного освещения Влияние атмосферы на свет, иногда именуемое воздушной перспективой (aerial per- spective), можно свести всего к двум явлениям. Во-первых, свет, проходящий через нашу атмосферу, с увеличением расстояния во многом теряет свою цветовую насыщенность. Кроме того, наблюдается легкий сдвиг цвета, вызванный тем, что атмосфера поглощает или рассеивает при прохождении через нее света определенную часть спектра цветов. Поглоще- ние и рассеяние обесцвечивают в основном синюю часть спектра - примерно в 10 раз
246 Глава 11 интенсивнее, чем красную или зеленую. Еще одна интерпретация этого феномена состоит в том, что, проходя сквозь атмосферу, свет теряет свой синий цветовой компонент, излучая его во всех направлениях. Суть второго явления в том, что рассеянный свет сам становится своего рода источником света. Рассеянные атмосферой лучи дополнительно окрашивают свет, падающий под тем углом, под которым сами были рассеяны. Этот феномен известен как внутреннее рассеяние (in-scattering). И снова, коль скоро рассеянным оказался синий компонент света, источник внутреннего рассеяния стремится приобрести синий цвет. Это не всегда так, но таково обоб- щение, которым мы будем пользоваться при создании нашей системы освещения сцены. Свидетелями этих явлений мы можем стать, совершив короткую прогулку на откры- том воздухе. Небо - смесь полупрозрачных газов, над которой лишь черная пустота, - кажется нам голубым, когда на нем светит солнце. По мере того как солнечные лучи про- ходят сквозь атмосферу, они теряют свою синюю составляющую и излучают ее. Часть этого излученного света попадает в наши глаза, отчего атмосфера над головой кажется синей. Достигшие земли лучи света растеряли свой синий цвет пропорционально расстоя- нию, которое прошли в атмосфере. Это еще более очевидно в то время, когда солнце скло- няется к горизонту и его свет вынужденно идет по касательной, проходя более длинный путь в атмосфере. При заходе солнца за горизонт потеря синего меняет цвет лучей на более красный, что вызывает эффект ярко-алой зари. Созданию адекватных моделей света, проходящего сквозь атмосферу и, в общем случае, сквозь пылевые частицы, было посвящено много работ академического характера. Самой заметной из них является работа, авторами которой стали Э. Дж. Притэм (A. J. Preetham) и Натаниэль Хоффман (Nathaniel Hoffman) [Preetham, Hoffman]. В своих исследованиях Притэм и Хоффман предлагают точную модель отображения эффектов дневного света в сценах на открытом пространстве. Их статьи (см. раздел «Литература» или приложение D «Рекомендуемая литература») содержат подробный анализ влияния воздушной перспективы и ее возможного применения в составе подобных сцен. Не пере- сказывая здесь эту работу, мы предлагаем названные материалы как источник более подробной информации для изучения. Рассмотрим применение того метода, что предлагают Притэм и Хоффман. При этом будем использовать ряд констант, которые присутствуют в формулах сдвига цветов и рас- сеяния света и получены в работе, представленной этими авторами. Подробности вывода этих констант мы приводить не будем, так как иначе нам понадобится детальнее описы- вать процесс прохождения света сквозь атмосферу, наполненную твердыми частицами, и аэрозоли. Вместо этого мы примем константы, которые рассчитали Притэм и Хоффман, за номинальные значения и посмотрим, как их можно использовать в нашей сцене. Для начала выберем кратчайший путь и эмулируем влияние атмосферы на цвет самого солнца. Хотя цвет солнца в разное время дня можно с большой точностью [Ргее-
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 247 tham] рассчитать для любого дня года или времени суток, мы полагаем, что в большинстве реальных задач это по-настоящему и не нужно. В нашем движке солнце каждый день будет проходить по одной и той же дуге, и мы не будем учитывать сезонные изменения в его беге. Поэтому смену цветов лучей солнца можно заранее без труда рассчитать и ин- терполировать ее на любой момент времени за день. Предпримем еще одно упрощение и заявим - солнце проходит у нас прямо над головой и достигает своего высшего положе- ния (зенита) в полдень. В этот момент солнце будет светить ярче всего и иметь желтова- тый оттенок. В полдень мы эмулируем направленные вертикально лучи света солнца, которые проходят сквозь атмосферу, имеющую минимальную толщину. На линии гори- зонта мы сдвинем цвет солнечного света в направлении красного, причем сделаем это как при восходе, так и при заходе светила. Так мы эмулируем лучи солнца, преодолевающие большее расстояние в атмосфере, где они идут практически горизонтально. Для управления данными о дневном свете мы создадим новый класс, именуемый cLightManager. Этот класс ответствен за управление всеми условиями освещения сцены и глобально доступен как член единственного экземпляра класса cGameHost. Кроме него мы опишем директиву препроцессора, дающую объектам возможность дос- тупа к менеджеру освещения при помощи простого макроопределения LightManager, выполняющего обращение к единственному экземпляру указанного ранее класса. В данный момент наш менеджер отвечает за единственный источник света - за солнце. Если бы нам потребовалось внести в окружающую среду новые источники света, то класс cLightManager показал бы себя удобным местом хранения и этих данных. Внутри cLightManager мы будем отслеживать прошедшее время, выражая его в ра- дианах. Полный оборот вокруг нашего мира солнце совершает за каждые 2п единиц вре- мени. Примем, что оно обращается вокруг мировой оси х (вертикальной же осью в нашем примере сцены является положительная ось z), тогда расчет вектора, направленного на солнце, станет делом отыскания синуса и косинуса значения времени и отождествле- ния с ними х- и z-составляющих искомого вектора. Класс cLightManager реализует эти действия как элемент функции обновления. Как только вектор на солнце рассчитан, его z-c оставляющую (высоту солнца по верти- кали) можно использовать для получения сдвига между цветом солнечного света в полдень и цветом солнца у горизонта. Эти цвета хранятся в виде констант в классе cLightManager. При создании класса мы рассчитаем названные цвета по формулам [Preetham] и сохраним эту пару значений для дальнейшей интерполяции. Чтобы достичь большей точности, мы можем рассчитывать эти значения в каждом кадре по мере движения солнца, однако интерполяция дает приближенное решение гораздо быстрее. Класс cLightManager содержит функцию- член computesunlightcolor, которая при его построении служит для генерации двух оттенков солнечного света, необходимых в ходе интерполяции Кроме того, эта же функция может использоваться в отдельных кадрах, если цвет солнца нужно определить точнее.
248 Глава 11 Теперь, когда цвет солнечного света рассчитан на ЦП, последняя задача - описать структуру данных, которую вершинный шейдер сможет использовать для отображения сцены при таком освещении и наличии ряда условий, характеризующих атмосферу. Как было сказано выше, свет, проходя сквозь нее, испытывает на себе воздействие двух процессов. По мере того как свет от изображаемой вершины преодолевает атмосферу, его оттенок меняется. Это означает потерю цвета с расстоянием - явление, именуемое затуха- нием (extinction). Помимо того наших глаз достигает известная часть солнечного света, рас- сеянного в атмосфере. Он дополнительно окрашивает отображаемую точку и носит назва- ние внутреннего рассеяния (in-scattering). На рис. 11.1 дано визуальное представление того и другого. Обратите внимание, что затухание есть функция расстояния, а внутреннее рас- сеяние зависит главным образом от угла между направлением взгляда и солнцем. Рис. 11.1. Затухание и внутреннее рассеяние света Затухание - способ представить свет, который теряется из-за наличия в воздухе твердых частиц, поглощающих либо рассеивающих его на пути к наблюдателю. Полное рассеяние мы представим уравнением 11.1. В этой формуле 5 есть расстояние от наблюдателя до данной точки. /?^и /7; - два коэффициента, которые описывают степень светорассеяния в атмосфере. Они характеризуют две модели представления рассеяния света, известные как рассеяние Релея и рассеяние Ми. Подробное описание каждой из них можно найти в статьях [Preetham, Hoffman], Нам, чтобы пользоваться ими, достаточно понимать, что релеевское рассеяние описывает эффект рассеяния на самых мелких, а модели Ми - на более крупных, шарообразных частицах. Объединение тех и других зна- менует итоговая формула рассеяния.
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 249 F( (s) = е (ii.i) Говоря более лаконично, формула 11.1 показывает, что затухание (F3) является функ- цией расстояния (5). Заметьте, что расстояние - это реальное значение, как правило, от О i до 5000 метров или около того, а не удаленность в пространстве наблюдения и не длина ' проекции. Чтобы найти затухание, мы сложим коэффициенты рассеяния Редея и Ми умножим сумму на расстояние и вычислим экспоненту этого произведения, взя- того с противоположным знаком. В нашем коде мы будем использовать заранее найден- ные константы коэффициентов Редея и Ми, однако позволим их масштабировать для получения разных атмосферных эффектов. Чтобы увидеть влияние релеевского рассеяния f и рассеяния Ми на солнечный свет, надо поставить несколько экспериментов, но все же проще его понять в контексте внутреннего рассеяния. Внутреннее рассеяние есть мера солнечного света, который находящиеся в воздухе частицы отражают в направлении наблюдателя. В известном смысле это мера цвета, который сообщают нам сами эти частицы. Формула для определения степени внутреннего рассеяния в данной точке есть функция как расстояния до наблюдателя, так и угла между падающим в точку солнечным светом и направлением наблюдения. Этот расчет доста- ; точно сложен, о чем свидетельствует уравнение 11.2. La (s,0)=e(1 (11.2) Эта «некрасивая» формула показывает, что внутренне рассеянный свет (/в) является функцией и расстояния, и угла между наблюдаемой точкой и солнцем. Е представляет цвет солнца, который умножается на единицу за вычетом затухания, найденного по урав- нению 11.1. Коэффициенты Редея и Ми призваны образовать отношение, которое обес- печит еще более тонкую настройку лучей солнца, моделируя фактический цвет лучей, отраженных от частиц в данной точке. Знаменателем этого отношения является сумма обоих коэффициентов, числитель же требует дополнительных вычислений, что и пока- зано в уравнениях 11.3 и 11.4. (11.3) />.»? = 2- Д,1-----------Д---------------гт) 4 я (1 + о2 - 2g cos(£?))3/2 (11.4)
250 Глава 11 Формулы освещения остаются такими же «симпатичными». Уравнения 11.3 и 11.4 определяют угловые коэффициенты Редея и Ми по заданному углу между направлением наблюдения и траекторией лучей солнца. Эти формулы аппроксимируют свет, отражен- ный частицами во всех направлениях, и пытаются установить долю этого света, собран- ного под конкретным углом наблюдения. В формуле коэффициента рассеяния Ми ска- лярное значение (g) служит для управления влиянием О на окончательный результат. Большее значение g (от 0 до 1) создает эффект сужения углового диапазона лучей, которые испускают частицы. В случае с солнцем видимым эффектом этого может стать уменьшение числа светящихся частиц вокруг его диска. Ключом к пониманию процесса вывода формул опять являются работы Притэма-Хоффмана. Мы же, решая свои задачи, будем и дальше обращаться главным образом к их реализации. По предложению Хоффмана [Hoffman] пиксельный шейдер может решать задачу расчета влияния как затухания, так и внутреннего рассеяния коррекцией цвета сцены на значение затухания и прибавлением к результату света, полученного путем внутрен- него рассеяния. Эта формула приведена в уравнении 11.5. Попросту говоря, итоговый, выходной цвет зависит от расстояния и угла между наблюдаемой точкой и солнцем. Исходный цвет (£исх) умножается на результат функции затухания. Затем к произведению прибавляется внутренне рассеянный свет, что и дает окончательный цвет лучей света. £(5,0)=£;/а.Рл(5)+£в(5,0) (115) Сложность состоит в том, чтобы найти затухание и внутреннее рассеяние, которые будут переданы в пиксельный шейдер. Чтобы в каждой вершине рассчитать эти значения, которыми будет пользоваться пиксельный шейдер, необходим достаточно большой шейдер вершин. Листинг этого вершинного шейдера, написанного на основе авторского шейдера Хоффмана и Притэма, показан в листинге 11.6. Мы поместим эту функцию в файл заголовков, с тем чтобы ею могли пользоваться несколько HLSL-шейдеров. ЛИСТИНГ 11.6. Вершинный шейдер рассеяния света для расчета затухания и внутреннего рассеяния как цветов диффузного и отраженного излучения // // Данный шейдер предполагает, что ему // передаются вершина в мировом пространстве // и значения расстояния, характеризующие // реальную удаленность. В структуре atm // передаются заранее рассчитанные данные // об атмосфере. Расчеты для установки этих // констант см. в исходном коде на CD-ROM.
РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ 251 // Пользуясь положением вершины и камеры // в мире, найдем "глазной" вектор и // расстояние в мировой системе координат. float3 eyeVector = vCameraPos - worldPos; float s = dot(eyeVector, eyeVector); s = 1. Of/sqrt (s') : eyeVector.rgb *= s; s = 1.0 f / s; // вычислим косинус угла "тета" flpat cosTheta = dot(eyeVector.rgb, sun_vec.rgb); // вычислим ответственный за затухание член Е // -(beta_l+beta_2) * s * log_2 е float4 Е = -atm.vSumBetalBeta2 * s * atm.vConstants.у; E.x = exp(E.x); E.y = exp(E.y); E.z = exp(E.z); E . w = O.Of; // Вычислим члены с "тета", необходимые для отыскания // внутреннего рассеяния. Вычислим p2Theta по формуле // (1-дЛ2) /(l+g-2g*cos(theta))Л (3/2) // примечание: // "тета" есть 180 - фактическое значение "тета" // (для коррекции знака) // atm.vHG = 1+д, 2д] float plTheta = (cosTheta*cosTheta) +atm.vConstants.x; float p2Theta = (atm.vHG.z*cosTheta) +atm.vHG.y; p2Theta = 1.Of/(sqrt(p2Theta)); p2Theta = (p2Theta*p2Theta*p2Theta). ‘atra.vHG.x; // вычислим внутреннее рассеяние (I) по формуле // (vBetaDl*plTheta + vBetaDl*p2Theta) * // (1-E) * atm.vRcpSumBetalBeta2 // // atm.vRcpSumBetalBeta2 = // l.Of/ (Rayleigh+Mie) float4 I = ((atm.vBetaDl*plTheta)+ (atm.vBetaD2*p2Theta)) *(atm.vConstants.x-E) *atm.vRcpSumBetalBeta2;
252 Глава 11 // масштабируем внутреннее рассеяние и затухание // для получения нужного эффекта (необязательно) I = I*atm.vTermMultipliers.х; Е = E*atm.vTermMultipliers.у; // пользуясь монохромным скаляром Ламберта, // уменьшим внутреннее рассеяние на неосвещенных // поверхностях. Этим мы внесем небольшое смещение, // которое позволит части внутреннего рассеяния // проникнуть в неосвещенные зоны float NdL = dot(v.Norm, sun_vec); I = I*saturate(NdL + 0.33f); // передадим цвет и интенсивность // солнечного света каждому члену //и выведем результат Out.vl.xyz = I*sun_color*sun_color.w; Out.vl.w = NdL*NdL; Out.vE.xyz = E*sun_color*sun_color.w; Out.vE.w = NdL; В шейдере было сказано о существовании структуры, наполненной заранее рассчи- танными значениями и содержащей все данные об атмосфере. Опишем на стороне прило- жения класс, который, пользуясь значениями времени выполнения, и сконструирует дан- ные. Это позволит настроить отношение коэффициентов рассеяния на частицах Редея и Ми, а также масштабировать полученные значения затухания и внутреннего рассеяния света. В исходном коде для этого предназначен объект cLightScatteringData, являющийся членом объекта cLightManager. Этот класс сводит воедино все атмо- сферные коэффициенты и выполняет максимально возможный объем предварительных расчетов над этими данными. Затем все множество данных об атмосфере, представленных в виде структуры, совместно выгружается в используемый файл эффектов. Чтобы учесть возможность постановки экспериментов, мы предусматриваем подстройку атмосферных условий во время работы программы. Увеличение коэффициентов подобно выбросу в атмосферу большего числа частиц, которые они пред- ставляют; при этом сам выброс может произвести значительный визуальный эффект. Так, коэффициент Редея характеризует мелкие частицы и прозрачные газы. Такие частицы можно считать капельками воды. Рост их числа ведет к более сильному рассеянию в небе синей составляющей света и общему увеличению яркости солнца. Коэффициент Ми представляет куда более крупные образования. Одним из примеров последних являются загрязняющие агенты. При увеличении коэффициента Ми рассеянный на этих крупных частицах свет соз- дает в воздухе плотный серый туман, во многом напоминающий смог или затянутое обла-
253 РЕНДЕРИНГ СЦЕН НА ОТКРЫТОМ ПРОСТРАНСТВЕ ками небо. Ставить эксперименты с этими значениями дает возможность расположенная на прилагаемом компакт-диске демонстрационная программа chapterll_demoO. Минус такого светорассеяния состоит в том, что при создании гладко интерполиро- ванных результатов наша реализация зависит от представления модели неба в виде мелких мозаичных элементов. Если бы мы наверняка знали, какие именно значения хотим использовать в процессе рассеяния света, то могли бы устранить зависимость от вершин- ного шейдера и заранее вычислить в виде текстур таблицы затухания и внутреннего рассеяния. В первом случае нами могла быть задана одномерная текстура со значениями затухания в указанном диапазоне расстояний. Внутреннее рассеяние мы могли бы представить стандартной 20-текстурой, одна ось которой отражала бы диапазон расстоя- ний, а другая - угол в направлении солнца. Объединение результатов - Чтобы увидеть результаты всех трех этапов, описанных в этой главе, откройте демонстрационную программу chapterll_demoO на прилагаемом компакт- диске. Она включает в себя все три новых этапа рендеринга, нацеленных на окончатель- ный результат. На уровне исходного кода сцена отображается трижды. Каждый раз объек- ту cGameHost передается новое значение признака этапа рендеринга. Это вынуждает все отображаемые объекты помещать в очередь на рендеринг тот или иной шейдер. Каждый шейдер располагает информацией об альфа-смешении, необходимой для объединения его выхода с содержимым буфера кадра. Литература [Preetham] Preetham, A. J., Р. Shirley, and В. Smits. «А Practical Analytic Model For Day- light». Siggraph proceedings 1999 (работа доступна по адресу www.cs.utah.edu/vissim/ papers/sunsky). [Hoffman] Hoffman, N., and A. J. Preetham. «Rendering Outdoor Light Scattering in Real Time» (работа доступна по адресу www.ati.com/developer/dx9/ATI-LightScattering.pdf).
Глава 12 ЗО-САДОВНИК Включение конвейера многоэтапного рендеринга в состав движка дает возможность заполнить наш мир разнообразными объектами. В этой главе мы обратимся к простым способам создания в нашей среде растительного покрова, травы и деревьев. Сделаем это мы для того, чтобы придать нашей сцене дополнительную реалистичность. При этом мы не будем чересчур углубляться в такие приемы управления геометрической формой объектов, как динамическая генерация уровней детализации (LOD). Взамен мы органи- зуем собственный класс геометрии cModelResource, способный заключать в себе дис- кретные версии модели при разных уровнях детальности представления. Поступим же так мы потому, что наши модели будут совместно использоваться в пре- делах всей карты. И даже если мы загрузим одну модель дуба, мы будем пользоваться ею в разных точках на карте. Наши объекты cSceneModel будут содержать данные об экземпляре каждого дерева и обращаться к общему ресурсу cModelResource, в котором хранится его реальная геометрия. Так как к одной модели будет обращаться целый ряд объектов cSceneNode, представить модельную геометрию, пользуясь динамическими LOD, окажется нелегко. Даже отличаясь высокой скоростью, методы, которые работают с динамическими LOD, а среди них - используемый прогрессивным мешем D3DX (объектом !D3DXPMesh), в такой ситуации обычно считаются непригодными. Всякий раз, когда уровень детализа- ции этих динамических мешей меняется, внутренняя геометрия подвергается пересчету. Это единовременные издержки, возникающие при каждом изменении LOD и достаточно небольшие, если они возникают от случая к случаю. В нашем сценарии будет присутство- вать множество объектов cSceneModel, пытающихся использовать общий объект типа «меш» при разных уровнях детализации. Применение для хранения геометрии таких про- грессивных мешей, как !D3DXPMesh, понудит нас обновлять уровень детализации исход- ного меша при отображении каждого дерева. Это может привести к сотням обновлений прогрессивного меша на кадр, что сделает нашу работу очень неэффективной.
зо-сддовник 255 К счастью, мы уже несколько сгладили эту проблему, используя очередь на рендеринг. При постановке в нее моделей те сами указывают желательный для них уровень LOD как 4-битное числовое значение. Это позволяет задать 16 уровней детализации каждого из объектов. По ходу сортировки очередью на рендеринг своих элементов по методу ото- бражения и индексам моделей она объединяет и сходные значения LOD каждой модели. Коль скоро эти значения LOD являются всего лишь частью того совокупного значения, которое участвует в сортировке, эта возможность становится «бесплатным приложением» как элемент сортировки, которую мы уже производим. Когда же элементы отсортирован- ной очереди будут отображаться, мы сможем дать себе отдых, зная, что экземпляры каждой модели, которые используют одну и ту же версию LOD, будут сгруппированы, что даст нам возможность уменьшить число раз, когда прогрессивный меш потребует своей обработки. Преследуя свои цели, допустим попеременное применение стандартных объектов ID3DXMesh и ID3DXPMesh. Объект нашего класса cModelResource может содержать в находящемся в нем дереве контейнеров мешей кадра объекты того и другого типа. Класс cModelResource имеет простую функцию-член setLOD, которой может быть передано значение от 0 до 16, причем 16 означает наивысший уровень LOD. Применение объектов lD3DXPMesh меняет структуру хранящейся в составе прогрессивного меша геометрии, для чего служат интерфейсные методы, предоставляемые библиотекой D3DX. Если же стан- дартные объекты iD3DXMesh используготся'так, как они использовались до сих пор во всех представленных демонстрационных программах, то функция-член setLOD не делает ничего. В каждом методе, который будет показан ниже, применение динамических LOD с уча- стием объектов iD3DXPMesh предполагается всюду, где это возможно. Тот факт, что мы используем прогрессивные меши, никак нс влияет на сами методы, поскольку те работают с мешами обоих типов. Следствием же применения сеток являются те тонкости отображе- ния, к которым вы можете прибегать для улучшения внешнего вида моделей с малым числом многоугольников в общем и тех, что служат для представления небольших расте- ний и травяного покрова, в частности. При отображении крупных объектов, таких, как деревья, мы будем полагаться исключительно на реализующий функции LOD-менедж- мента объект !D3DXPMesh. Суррогаты растительности В нашей книге суррогатом (impostor) мы называем слабо детализированный объект, показанный для передачи облика объекта, имеющего гораздо большую детализацию. Самым простым и распространенным суррогатным объектом является билборд (billboard). Билборд - это не более чем плоская поверхность, обычно включающая в себя всего лишь несколько треугольников, на которые наносится заранее полученный вид объекта с высо- ким уровнем детализации. Если изображение имеет йльфа-канал, простой проверкой его
256 Глава 12 содержимого можно маскировать те участки билборда, которые не предназначены для просмотра. Так, DirectX SDK содержит пример программы вывода изображений деревьев, показанных на билбордах в условном ЗО-пространстве. Даже если реально каждое дерево - это попросту билборд, изображение на нем с проверкой альфа-канала дает нам облик дерева с большим числом деталей. Если мы используем единственный билборд, он должен непрерывно вращаться, для того чтобы быть повернутым к камере. В то время, когда билборд виден с ребра, эффект пропадает, и становится очевидной конструкция билборда, который не толще бумаги. Объект cCamera содержит матрицу, которая может использоваться для поворота бил- бордов с единственной целью ориентации их на камеру. Еще одна проблема, с которой необходимо справляться, состоит в том, что у нас есть только одно изображение, которое мы помещаем на билборд. И даже если мы сможем осуществить поворот геометрии для предотвращения попадания в камеру вида на билборд с ребра, мы будем всегда видеть в камере одно и то же изображение независимо от того, как ориентирован билборд. Это создаст видимость, будто наши объекты кружатся по мере того, как камера проходит мимо, поскольку ей объект всегда будет виден под одним и тем же углом. Возможный путь решения этой проблемы - использовать множество билбордов для представления каждого из объектов. Для этого мы выставим вокруг оси несколько бил- бордов так, чтобы каждый из них «смотрел» в своем собственном направлении. Вид на эту конструкцию сверху показан на рис. 12.1, где изображено множество таких расставленных по радиусам билбордов. На каждый из них мы поместим заранее построенное изображение объекта при взгляде на него в перпендикулярном билборду направлении. Коль скоро отдель- ными билбордами мы покрываем целый ряд углов зрения на объект, необходимости вращать билборды ради наведения их на камеру уже нет. Взамен при отображении набора билбордов мы будем рассчитывать скалярное произведение каждого билборда на наш угол зрения. Показан же будет лишь тот билборд, который ближе других окажется к перпендикуляру к вектору наблюдения (то есть даст наибольшее скалярное произведение; см. рис. 12.1). Прием с множеством билбордов требует создания текстур, необходимых для нанесе- ния на каждый билборд. В пакете 3D Software объект должен быть представлен под целым рядом углов - по одному на каждый искомый билборд. Чтобы облегчить отображение набора билбордов, мы сгруппируем все билборды в одну-единственную модель. Это позволит нам визуализировать весь набор сразу, используя вершинный шейдер для выбора тех билбордов, которые видны наблюдателю. Для отображения текстур на объекте необ- ходим еще один шаг настройки. Вместо того чтобы использовать заранее подготовленные текстуры объекта незави- симо, мы можем свести их в единую текстурную карту. При этом каждому билборду назначается ряд текстурных координат, необходимых для чтения нужной области на этой
ЗО-САДОВНИК 257 карте текстуры. Если мы гарантируем, что между всеми изображениями, которые содержат прошедшие альфа-тест прозрачные зоны, имеются достаточные просветы, то сможем вывести очередной суррогат как одну модель и единую текстурную карту. Рис. 12.1. Множество билбордов, используемых для имитации модели с большей детализацией. Как видно при взгляде сверху, для вывода объекта служат восемь билбордов. Виден (показан черным) лишь «самый перпендикулярный» из них по отношению к камере Одно из замечательных свойств этого метода состоит в полной прозрачности для игрового движка. Все установки - создание композитной текстуры и организация набора билбордов - делаются в режиме офф-лайн. То, какие грани следует вывести на экран, определяется файлом эффектов, движок за это не отвечает. Для нашего игрового движка подобные суррогаты - просто модели, как и любые другие, характеризуемые материалом поверхности и методами рендеринга. Чтобы создавать такие объекты или управлять ими, не нужен никакой специальный код. Недостатком этого метода является освещение. Сами билборды суть плоские поверх- ности, и именно так их будет освещать модель постановки света. Помочь замаскировать эти проблемы может наложение придающих объектам более объемный вид рельефных текстур, однако мы никогда не сможем аппроксимировать то, каким могло бы стать реаль- ное освещение объектов, имеющих высокую детализацию. Трава - «меховой покров» матери-природы Короткие травинки можно изображать, используя прием, аналогичный визуализации меха. Типичный способ представить покрытые мехом объекты - использовать кон- центрические оболочки (shell), каждая из которых чуть больше предыдущей. Внутри каждой оболочки накладывают текстурную карту. Последняя содержит точечно-пунк- тирный цветной шаблон меха и попиксельные значения высоты каждого волоса. Высот- ные значения сохраняются в альфа-канале текстуры. Текстура представляет поперечное сечение меха и высоту, на которую отрос каждый волос. IS V)
258 глава 12 При отображении каждая оболочка анализирует альфа-канал с тем, чтобы определить, какие пикселы требуют вывода на экран. Опорным значением альфа-канала в ходе про- верки толщины каждой оболочки служит минимальная высота волос, достигающих ее толщины Пользуясь этим тестом, мы отобразим лишь те пикселы, что представляют щетинки, пересекающие плоскость оболочки. Если такие оболочки собрать воедино, то результатом станет правдоподобный вид волос на объекте. Данный метод напрямую применим и к коротким травинкам. Если мы отобразим участок ландшафта несколько раз, приподнимая уровень земной поверхности при каждом проходе, то можем воспользоваться этим же методом. На рис. 12.2 показано сечение нашего ландшафта, в котором используются четыре оболочки, имеющие различную тол- щину. Они служат иллюстрацией ландшафта, изображенного четырежды с легким повы- шением уровня земной поверхности при каждом проходе. На каждый слой наносится карта текстуры Она содержит попиксельные значения высот отдельных травинок. На рис. 12.2 все травинки представлены в виде черных полос. Внутри каждого слоя мы хотим видеть травинки, высота которых не ниже толщины самого слоя. С каждым слоем свяжем значение, которое участвует в альфа-тесте и представляет его толщину. При этом будут нарисованы лишь те пикселы, которые содержат альфа-значения, превышающие опорное значение слоя. На рис. 12.2 в каждом слое они показаны выделением линий. Рис. 12.2. Применение множества плоскостей с проверкой альфа-канала для имитации объемного вида травы Результатом этого становятся травинки, созданные объединением отдельных слоев. Каждый слой отображает лишь их поперечное сечение, но вместе они образуют види- мость полной пластинки листа. Другой способ изображения травы действует подобно
ЗО-САДОВНИК 259 стопке монет. Каждый проход по ландшафту рисует одну монету, однако, сложенные поверх друг друга, они создают вид полной стопки. Этот метод ждет крах при построении вида сбоку. Как видно из рис. 12.2,.между отдельными слоями есть существенный промежуток. Если же попытаться использовать данный эффект, чтобы покрыть травой холм, то трава на вершине может принять вид разорванных, пунктирных линий. Это вызвано тем, что угол зрения дает нам возможность видеть пустоты между слоями, где травы нет. Чтобы скрыть такой артефакт, мы должны добавить в нашу систему вертикально расположенные многоугольники, содержащие вид травы сбоку. Тогда с приближением вектора наблюдения к ракурсу, где он становится параллелен сложенным в стопку слоям травы, профильные многоугольники предотвратят попадание в камеру тех самых пустот. Вид этих многоугольников показан на рис. 12.3. Рис. 12.3. Профильные билборды, применяемые для предотвращения возможности видеть пустоты между плоскостями уложенной слоями травы Этот метод нетрудно продемонстрировать, используя плоские поверхности, однако наш ландшафт далек от того, чтобы быть плоским. Допустим, мы создаем модель, которая содержит набор слоев травяного покрова и вертикальных профильных плоскостей; но как же использовать их на нашем холмистом ландшафте? Ответ лежит в следующем приеме, которым мы можем пользоваться, имея дело как с травой, так и с другими объектами. 9*
260 Глава 12 Золотых колосьев волны Чтобы нарисовать низко лежащие объекты ландшафта, такие, как травы или иные виды растений, мы должны точно отображать их на контуры ландшафта как такового. Если бы мы осуществляли визуализацию отдельных мелких моделей, то могли бы попросту оценивать высоту и нормаль к поверхности в точке ландшафта под каждым объектом и пользоваться ими для переориентации объектов, расположенных на земле. Это позволило бы вывести на экран отдельные мелкие модели, а также саму поверхность ландшафта. Хотя этот метод и работоспособен, от эффективного решения он далек. Чтобы действовать более эффективно, нам нужен способ отображения более крупных моделей и деформации их с учетом ландшафтной поверхности. Подобные более крупные модели могут включать в себя множество образов объектов размером поменьше. Так, хорошим кандидатом на применение этого метода является наша многослойная модель травяного покрова. Вместо того чтобы пытаться отобразить каждый слой и каждый про- фильный билборд отдельно, мы можем сгруппировать их в более крупные модели и де- формировать, учитывая характер земной поверхности. Для этого воспользуемся методом деформации фрагментов, который включает приме- нение сетки значений для изменения формы модели. Каждая из наших моделей травяного покрова, примерами которых могут быть совокупность слоев травы или небольшая группа растений, организуется так, чтобы вписаться в квадратный участок земли. Его раз- меры могут меняться, однако лучше всего методика работает при сохранении малых размеров моделей. Вырежем из ландшафта под моделью 4 х 4-сетку высот и нормалей к поверхностям. Если модели созданы так, что по размерам это - участки 4x4 единицы длины ландшафта, то выборка нужных значений становится обычным просмотром таб- лицы высот и таблицы нормалей к поверхностям, хранящихся в объекте cTerrain. Затем эти 16 значений передаются вершинному шейдеру на деформацию. По мере отображения вершин модели мы будем определять, в какой из ячеек сетки в пределах нашего 4 х 4-набора данных они расположены. Как только ячейка сетки окажется обнару- женной, интерполируем четыре значения в ее углах, чтобы найти итоговое высотное значение по вертикали, а также нормаль к поверхности в анализируемой вершине. Резуль- татом воздействия этого метода на все вершины модели станет модель, вследствие деформации повторяющая контуры ландшафтной поверхности. В демонстрационной программе chapterl2_demo0 мы пользуемся этим прие- мом, работая с мелкими растениями и участками травяного покрова. Пример HLSL-функции, которую вы можете использовать для выполнения деформации, показан в листинге 12.1. В исходном коде можно найти еще ряд подпрограмм деформации фраг- ментов, которые тоже интерполируют нормали к поверхностям, давая возможность убедиться, что искаженная геометрия покрова как следует освещена.
ЗО-САДОВНИК 261 ЛИСТИНГ 12.1. Вершинный шейдер на базе деформации фрагментов, используемый для отображения объектов на контуры ландшафтной поверхности // объявим сетку 4x4 из // мировых высот и нормалей // к поверхностям struct PatchPoints { float4 normh[16] ; }; PatchPoints pc : patchcorners; // функция деформации вершин при // помощи значений высот фрагмента void PatchDeformf in out float3 Pos, uniform float4x4 mWorld) { // примечание: // мы полагаем, что масштаб исходной модели // подобран так, чтобы она вписалась в участок // размером 3x3 единицы. // Найдем целочисленные координаты по х,у, а также // скаляры, основанные на параметрах ячейки сетки int х; int у; float sx = modf(Pos.x, х) ; float sy = modf(Pos.y, у); // вычислим индекс в нашем массиве // значений 4x4 и прочитаем четыре // значения в углах данной ячейки, // мы внутри int index = (у*4)+х; float4 zO = pc.normh[index]; float4 zl = pc .normh[index+1] ,- float4 z2 = pc.normh[index+4]; float4 z3 = pc.normh[index+5]; // интерполируем четыре угла, // используя значения sx и sy float4 zl = lerp(zO,zl,sx); float4 zh = lerp(z2,z3,sx); float4 zi = lerp(zl,zh,sy); // переведем нашу точку в мировое // пространство и переместим ее на // полученную интерполяцией высоту zi Pos = mul(float4(Pos, 1), mWorld); Pos.z = Pos.z+zi.w;
Глава 13 ОКЕАНСКИЕ ВОДЫ В этой, заключительной главе книги мы отойдем от земной суши и добавим в наш движок принципиально иной тип поверхности - воду. Точнее, мы создадим толщу океан- ской воды, которую сможем использовать в нашей среде повсюду. Эти действия потребуют от нас процессорной реализации небольшой математически насыщенной анимации, цель которой - создать динамическое движение волн в глубине океана. Эта анимация будет наложена на динамический меш вершин, составляющих океаническую поверхность. Необходимую для этого математику мы будем описывать, особо не углубляясь в расчеты, которые надлежит сделать. И хотя мы проведем обзор той работы, которая с ними связана, подробное описание актуальных для нас математических принципов выхо- дит за пределы самой книги. Ссылки на материалы для дальнейшего изучения по темам, которые мы обсуждаем, содержатся в приложении D «Рекомендуемая литература». Шаг первый - это подготовка суши к появлению на ней воды. Мы затопим ландшафт водой до определенного уровня, позволив ей разлиться в пределах всего рельефа. Тем самым воду мы увидим везде, где суша опустится ниже высоты уровня моря. Это позво- лит нам очертить водную границу острова и даст возможность создать озера в долинах нашего собственного ландшафта. Когда мы подготовим ландшафт к тому, чтобы на нем возникла вода, мы перейдем к изучению самой водной системы, а также проведем обзор той математики, которая участвует в процессе анимации волн в глубине океана. Знать базовые математические принципы - это одно, а эффективно реализовать их - совсем другое. Включая в нашу среду океан, мы поговорим о нескольких ключевых мето- дах анимации его меша во времени, не позволяющих частоте смены кадров упасть прак- тически до нуля. Когда создание геометрии будет завершено, мы сразу же начнем создавать шейдер, используемый для рендеринга воды. При этом мы получим окончательный резуль- тат и опишем свою последнюю демонстрационную программу для этой книги. Главу завершает представление этой программы, а также несколько соображений по поводу даль- нейших усовершенствований, которые по своему желанию может внести читатель.
ОКЕАНСКИЕ ВОДЫ 263 Остров посреди моря У нашего ландшафта всегда были рваные, зазубренные края. Границу нашего мира обозначал край использованной для построения ландшафта карты высот. Последнее, что мы хотим добавить к движку, - так это окружить свою карту водой. Для этого мы прежде всего настроим ландшафт, после чего введем в сцену водную составляющую. Для начала наша карта должна стать островом. А значит, мы должны гарантировать, что все вершины вдоль границы нашей карты высот погружаются в воду. В противном случае у нашего мира останется рваный край. Сформировать остров проще всего до того, как ландшафт будет реально построен. Внести изменения в саму карту высот, которая служит для образования острова, мы можем на уровне битовой карты. Для создания острова мы просто организуем цикл по карте высот и скорректируем каждый элемент на некое значение, чтобы изменить высоту. Если все сделано верно, мы можем опустить значения по краям карты ниже уровня моря, и это даст нам гарантию того, что ландшафт уйдет под воду, прежде чем внезапно оборвется по краю. С этой целью применим ко всей карте высот высотный масштабный коэффициент, который приблизит к нулю значения всех пикселов, лежащих ближе к периферии битовой карты. Чтобы его рассчитать, воспользуемся нелинейной функцией кривизны; при этом пикселы в центре карты высот будут по большей части нетронуты. Для получения кривиз- ны построим вектор от конкретного пиксела до центра карты. Далее, чтобы узнать нашу удаленность от центра карты, найдем длину этого вектора. Поделим ее на половинное разрешение битовой карты и получим результат в диапазоне между 0 и 1. Затем, чтобы создать нелинейность, возведем это значение в квадрат. В результате итоговый масштаб- ный коэффициент карты высот есть единица минус полученное значение. На рис. 13.1 эти значения представлены в формате битовой карты. Рассчитанные пиксел за пикселом, созданные скаляры будут напоминать показанное здесь изображение в оттенках серого. Умножив каждый пиксел этой битовой карты на высоту из карты высот, мы, в самом деле, опустим края своей карты ниже уровня моря. Как показывает рис. 13.1, наша процедурная настройка контуров острова может при- вести к образованию чересчур округлых его очертаний. Более внимательный взгляд на процесс дает простое средство против подобных исходов. Наше обращение к 2О-век- тору из центра карты до каждой точки на карте высот, по сути, породило вторую высотную карту, на которую мы умножаем оригинал. Именно этот процесс и показан на рис. 13.1. Однако вместо попиксельного создания этой корректирующей карты с участием 2И-век- торов мы можем просто заранее построить ряд привлекательных островных контуров и сохранить их на жестком диске как файлы высотных карт. Когда же процедурная карта высот нашего случайного ландшафта будет построена, мы попросту умножим ее на один из этих шаблонов острова, чтобы выбрать ту или иную форму его очертаний.
264 Глава 13 Рис. 13.1. Управляющие значения на уровне пикселов служат для превращения карты высот в остров С—Этот подход мы возьмем на вооружение в программе chapterl3_demo0.exe 4— ' на прилагаемом компакт-диске. Результатом ее работы станет ландшафт, который по-прежнему строится случайно в смысле высот и текстуризации, однако заимствует общие очертания острова из текстуры, созданной нами заранее. Легкость применения этого метода с лихвой возмещает потерю случайного характера ландшафта в полном смысле этого слова. Стремясь внести больше элементов случайности, читатель может попробовать добавить шум или реализовать наслоение множества шаблонов островных очертаний. Водная мозаика Прежде чем мы узнаем о том, как будем анимировать воду, нам нужно определить самый лучший способ расчета больших участков поверхности, которые мы хотим запол- нить водой. В нашей реализации мы хотим окружить рельеф острова водой и позволить ей появиться всюду, где суша опустится ниже уровня моря. На деле это означает, что потен- циально вода на карте может быть где угодно и может разлиться, насколько хватит глаз, до самого горизонта. Этим обусловлена одна интересная проблема. Как гарантировать, что водная гладь в нашей среде есть повсюду? Ясно, что мы не хотим строить геометрические объекты, необходимые, чтобы покрыть водой весь ланд- шафт, если она заполняет его до бесконечности во всех направлениях. Однако океан должен выглядеть так, будто он простирается до этих бесконечных пределов. Чтобы решить эту проблему, прибегнем к тому же решению, которым мы пользовались в случае с еще одним элементом ландшафта, существующим везде и повсюду, - в случае с небосводом. Вспомните, что в главе 10 «Страна высокого неба» наша модель небосвода в реаль- ности была лишь крошечным фасадом, обращенным к точке расположения камеры. В ходе рендеринга на ней появляются удаленные образы и облака, окружающие наблюда- теля. Наш океан ничем не отличается от небес, и для отображения бесконечного моря океанской воды вокруг наблюдателя мы можем использовать аналогичный метод. Отличие же состоит в том, что модель небосвода отображалась в пространстве камеры
ОКЕАНСКИЕ ВОДЫ 265 фактически в отрыве от среды, в то время как вода должна выглядеть так, будто она - неотъемлемая часть ландшафта. Чтобы создать бескрайнюю толщу воды, воспользуемся фрагментом геометрии, который может быть включен как элемент в состав мозаики, которая покроет всю нашу среду. Он представляет собой покрытую водой зону известного размера. Чтобы создать видимость того, что водой залита большая площадь, мы будем просто многократно изо- бражать такой элемент, всякий раз смещая его в новую точку мирового пространства координат. Изображая этот элемент во всех видимых точках, мы можем создать впечатле- ние безбрежного моря. Это позволит нам создать настолько большую мозаику воды, насколько хватит поля зрения камеры; при этом не придется реально хранить какие бы то ни было долговременные данные о каждой залитой водой точке. Ограничительный прямо- угольник с выравниванием по мировой системе координат, который включает в себя поле зрения камеры, мы наложим на мировую сетку точек расположения водных мозаик. Сами элементы водной мозаики будут помещены во всех точках мировой сетки, пересе- кающихся с полем зрения камеры. Анимация водной поверхности За годы работы было описано немало приемов анимации волн океана и, в общем, поверхностей жидких сред. Небезынтересно заметить, что эти приемы изучались как в кругах ученых, занятых компьютерной графикой, так и в океанографической литера- туре. В том и в другом случае исследователи предлагалй методы моделирования и аппрок- симации процессов гидродинамики, ответственных за поведение водной поверхности. Преследуя свои цели, мы сосредоточимся лишь на одном из этих приемов. Литературу для дальнейшего изучения см. в приложении D. Наша цель, чтобы быть точными, - аппроксимировать движение волн в глубине океана, а не реальная имитация такового, основанная на параметрах физического характера. Гео- метрическая форма нашей океанской мозаики много раз повторяется в пространстве поля зрения камеры, поэтому наш метод анимации должен бесшовно соединять и мозаичные эле- менты. Это значит, что наше решение должно быть очень искусственным от природы. Оно включает в себя быстрое преобразование Фурье (FFT, Fast Fourier Transform), задача которого - объединить множество октав синусоидальных волн, деформирующих фрагмент геометрии и придающих ему вид чего-то очень волнообразного. Эффект такого рода с боль- шим успехом использовался во многих коммерческих пакетах 3 D-графики и в индустрии кино. Здесь мы проведем обзор этого метода, сосредоточившись на том, каким образом его можно использовать в решении задач в режиме реального времени. Математические принципы практического образования волн лежат за рамками книги, а потому в ней мы предпримем лишь краткий экскурс в область используемых понятий. Более подробные
266 Глава 13 объяснения можно найти в работах, авторами которых являются Джерри Тессендорф (Jerry Tessendorf) [Tessendorf], Ласс Стафф Йенсен (Lasse Staff Jensen) и Роберт Голиас (Robert Golias) [JensenGolias], Кроме того, для понимания необходимых математических принципов полезно ознакомиться с литературой, посвященной гауссовым случайным числам и преобразованиям Фурье. Вкратце, в основе этого метода нет никаких попыток создать модель вод океана путем физической имитации'. Напротив, чтобы сместить ряд вершин сетки, мы объединяем серию волн синусоидальной формы. Модель же задает основанный на наблюдениях и ста- тистическом анализе реального моря способ получения амплитуды и фазы этих синусои- дальных волн. FFT реализуется в двух измерениях (по вертикали и горизонтали по сетке вершин), что позволяет сложить синусоидальные волны в значения смещений на этой же сетке. Результатом при анимации во времени станет фрагмент колеблющейся волны, сильно напоминающей реальные океанские волны. Самым полезным свойством этого метода является то, что применение FFT дает результат, способный сложиться в мозаику по всем направлениям. Это дает возможность использовать метод анимации, основанный на FFT, для построения мозаик волны и гаран- тировать, что мы по-прежнему способны неоднократно изображать мозаичные элементы в большой среде. Надо заметить, что методы, связанные с объединением волн синусои- дальной формы, применялись к вершинным шейдерам напрямую [ShaderX], однако мы не будем считать это идеальным для нашего случая. Мы многократно будем отображать один и тот же объект - элемент водной мозаики, а потому имеет смысл один раз выпол- нить на процессоре вычисления, настроить тем самым динамический буфер вершин, а затем использовать минимальный вершинный шейдер для переноса элемента мозаики на ряд желаемых точек в мировом пространстве координат. Реализация любых действий по деформации волн в вершинном шейдере стала бы причиной избыточных расчетов синусных волн каждый раз, когда изображается элемент водной мозаики. Следует также отметить тот факт, что наша реализация метода FFT-анимации осно- вана на примере, приведенном Карстеном Венцелем (Carsten Wenzel) [Wenzel]. Принадле- жащий Венцелю пример программы анимации волн, который можно найти на его собст- венном Web-сайте и Web-странице для разработчиков NVIDIA (указаны в приложении D), служит для иллюстрации использования основанного на FFT метода в случае покадровой анимации фрагмента водной поверхности. Наша реализация - это версия работы Венцеля, в которую для достижения лучшей производительности во время прогона добавлена рас- пределенная обработка преобразования FFT. Вычисляя лишь значения высот для деформации меша, мы еще больше упростим данный метод. Анимация сетки вершин при работе с океанскими волнами, как правило, 1 Что потребовало бы гигантских вычислительных ресурсов. - Примеч. науч. ред.
ОКЕАНСКИЕ ВОДЫ 267 осуществляется для достижения двух результатов. Во-первых, высота каждой вершины сетки выбирается так, чтобы добиться нужной формы волны. Во-вторых, вершины обычно смещаются вдоль водной поверхности для лучшей аппроксимации отдельных волн, принимающих форму зыби. В нашей реализации процесс ускорится за счет вычис- лений только значений высот. Этим мы устраним немалую часть требуемых расчетов. Прежде чем мы сможем применять FFT, нам нужно создать начальный набор данных и анимировать их. Мы следуем предложению Тессендорфа и для создания множества амплитуд и фаз синусоидальных волн по параметрам ветра и высоте волн используем спектр Филлипса. Применение спектра Филлипса ведет к созданию набора начальных данных, которые, как было показано, являются хорошим приближением внешнего вида реальных волн в океане при объединении результатов двух розыгрышей на генераторе гауссовых случайных чисел. Пользуясь генератором гауссовых случайных чисел (находя- щимся в файле библиотеки движка random_numbers. срр), метод задает состояние океанских волн в момент времени 0. Первый шаг - расчет спектра Филлипса по тем вход- ным параметрам, которые описывают волны и ветер. Равенство, которое при этом исполь- зуется, показано в уравнении 13.1. (13.1) где: К - вектор движения волн, к - модуль вектора К, К- нормированный вектор К, W- направление ветра, I - максимальная высота волн, а - константа, управляющая высотой волн. Как видно из уравнения 13.1, начальный набор данных о каждой волне составлен из направления ее движения, ее же величины и глобального вектора, который описывает ветер. Помимо прочего, это уравнение накладывает отдельные ограничения на результат. Последний управляется постоянным скаляром (а), дающим возможность преувеличивать или преуменьшать величину волн, задавая большие или меньшие значения (а). Последняя часть уравнения, где происходит расчет модуля скалярного произведения нормированных векторов Ки W, управляет высотой волн в направлении ветра. Фактически она устраняет все составляющие волны, бегущие перпендикулярно направлению ветра, и сохраняет те части волны, что движутся по ветру и против него. Ограничением является и максимальное значе- ние высоты волн (/). Его можно найти по уравнению 13.2.
268 Глава 13 (13.2) где: v - скорость ветра, g - постоянная силы тяжести (9,81). Чтобы создать итоговый набор данных о начальном состоянии волн, спектр Филлипса для получения неких случайных вариаций волны объединяется с результатами двух розыгры- шей на генераторе случайных чисел. Гауссов генератор используется по эстетическим соображениям, поскольку, как было показано, числа, полученные по Гауссу, стремятся повторять данные наблюдений за океанскими волнами. Хотя другие формы распределений случайных чисел также будут работать, мы снова следуем указанию Тессендорфа и пользу- емся гауссовым генератором. Пример нашего гауссова генератора приведен в листинге 13.1. ЛИСТИНГ 13.1. Гауссов генератор случайных чисел для задания амплитуды и фазы океанских волн //: GaussRandom //------------------------------------------------- // // Выдает гауссовы случайные числа, пользуясь тригонометрическим // представлением преобразования Бокса-Мюллера (Box-Muller) // // :// // Тригонометрическое представление преобразования Бокса- // Мюллера способно за проход выдавать два гауссовых случайных // числа. Коль скоро для построения комплексных чисел нам // нужно, чтобы гауссовы значения следовали попарно, напишем // метод, который извлекает выгоду из этого обстоятельства, void gaia::GaussRandomPair( floats result_a, floats result_b, float dMean, float dStdDeviation) float xl, x2, w, yl, y2 ,- do { xl = 2.Of * random_unit_float() - l.Of; x2 = 2.Of * random_unit_float() - l.Of; w = xl * xl + x2 * x2; } while ( w >= l.Of ); w = sqrtf( (-2.Of * logf( w ) ) / w ); yl = xl * w; y2 = x2 * w;
ОКЕАНСКИЕ ВОДЫ result_a = ( dMean + yl * dStdDeviation ); result_b = ( dMean + y2 * dStdDeviation ); } c Влистинге 13.1 не показана подпрограмма random_unit_f loat (). Эта простая ------функция выдает случайное вещественное значение в диапазоне (0, 1). Для его получения могут использоваться любые средства. Наша конкретная реализация показана в примере исходного кода в файле randoiti_numbers . h на сопровождающем компакт-диске. Применяя гауссов генератор, мы можем собрать исходное множество данных. Для решения этой задачи воспользуемся еще одним «ужасающим» равенством. Как следует из уравнения 13.3, оно объединяет два случайных числа, выданных генератором Гаусса, и результат построения спектра Филлипса с целью задания начального множества волн в момент времени 0. Чтобы упростить дальнейшее применение FFT, организуем это мно- жество данных об амплитуде в пространстве Фурье: Мк) = с(£ + , (13.3) где: ^r' - гауссовы случайные числа с математическим ожиданием, равным 0, и среднеквадратическим отклонением, составляющим 1, Г’,(К) _ спектр Филлипса для вектора К, 1 с - константа . Результат вычислений по уравнению 13.3 над множеством из К-векторов есть список комплексных чисел, представляющих фазу и амплитуду синусоидальных волн, которые мы будем объединять. В примере исходного кода для отыскания 2Э-векторов К служат позиции сетки вершин по осям и у. В результате мы имеем столько же распределенных К-векторов, сколько вершин находится в нашем распоряжении. Чтобы анимировать данные, используем - для расчета значений синуса и косинуса во времени - угловые частоты каждого вектора К. Затем для анимации движения волн вос- пользуемся этими значениями в контексте комплексных чисел, ранее найденных в уравне- нии 13.3. Имея набор угловых частот, хранящихся в массиве, и набор решений уравнения 13.3, мы можем, используя код из листинга 13.2, реализовать саму анимацию. Набор ком- плексных чисел т_со1Н0 в этом листинге уже построен по уравнению 13.3 вкупе с набором угловых частот вектора каждой волны К. В силу того что К был выведен нами из положения наших вершин, мы можем использовать эти позиции и как подстановочные (lookup-) значе-
270 Глава 13 ния в таблице частот и комплексных чисел. Эти подстановки реализованы в виде простых inline-функций, преобразующих г- и у-компоненты векторов в значения индексов в пределах массива. Простой структурой, содержащей действительную и мнимую составляющую ком- плексного числа, является тип данных sComplex. ЛИСТИНГ 13.2. Анимация волн по таблице, заданной в пространстве Фурье void cOceanManager::animateHeightTable() { for(int j = -k_half_grid_size; j< k_half_grid_size; ++J ) { for(int i = -k_half_grid_size; i< k_half_grid_size; ++i ) { float fAngularFreq= m_colAngularFreq[ getlndex(i,j)] * m_fTime; float fCos=Cosine(fAngularFreq) ; float fSin=Sine(fAngularFreq); int indexFFT = getIndexFFT(i,j); int indexHO = getlndexHO(i,j); int indexHOn = getlndexHO(-i,-j) ; // обновим таблицу комплексных чисел, // влияющих на высоту водной поверхности. m_colH[indexFFT],real= ( m_colH0[indexHO].real + m_colH0[indexHOn].real ) * fCos - ( m_colH0[indexHO].imag + m_colH0[indexHOn].imag ) * fSin; m_colH[indexFFT],imag= ( m_colH0[indexHO].real - m_colH0[indexHOn].real ) * fSin + ( m_colH0[indexHO].imag - m_colH0[indexHOn].imag ) * fCos; } } } Последний шаг - перевод данных из пространства Фурье в пространственную область определения при объединении всех синусоидальных сигналов. FFT-преобразование сдела- ет все это за нас, однако лишь с той скоростью, на которую оно способно, поэтому для ани- мации воды в режиме реального времени мы видим необходимость в более эффективном
ОКЕАНСКИЕ ВОДЫ 271 решении. Для начала давайте посмотрим, в чем состоит FFT, после чего продемонстрируем, как выполнить эту работу более эффективно, пользуясь той или иной линейной интерполя- цией. Полное изучение теории FFT лежит за рамками этой книги. Мы же будем иметь дело напрямую с расчетом FFT, как это и показано в листинге 13.3. С Количество действительных и мнимых чисел, передаваемых подпрограмме в листинге 13.3, известно как k_grid_size (размерность сетки вершин). Второе значение, k_log_grid_size, также вычислено заранее и управляет циклическим выполнением FFT. Эти константы имеют больший смысл, если исходный код виден пол- ностью, а не так - вне контекста в листинге 13.3. Весь процесс построения, анимации и реализации FFT можно найти в классе cOceanManager на прилагаемом компакт-диске. Листинг 13.3. Реализация FFT-преобразования на множестве действительных и мнимых чисел void cOceanManager::FFT(float* real, float* imag ) { long nn, i, il, j,k,i2,1,11,12; float cl,c2,treal,timag,tl,t2,ul,u2,z; nn = k_grid_size; // инверсия бита i2 = nn > 1; j= 0; for(i= 0;i< nn - 1; ++i ) { if(i<j ) { treal = real[i]; timag = imag[i]; real[i] = real[ j ] ; imag[i] = imag[ j ] ; real[j] = treal; imag[j] = timag; } k = i2 ; while! k <=j) { j-= k; k >= 1; } j+= k; } // Осуществим FFT
272 Глава 15 cl = -l.Of; c2 = O.Of; 12 = 1; for( 1=0; 1 < k_log_grid_size; ++1 ) { 11 = 12; 12 «= 1; ul = 1.0; u2 = 0.0; for(j= 0;j< 11; ++j ) { for(i= j;i< nn;i+= 12 ) { il =i+ 11; tl = ul * real[ il ] - u2 * imag[ il ]; t2 = ul * imag[ il ] + u2 * real[ il ]; real[ il ] = real[i] - tl; imag[ il ] = imag[i] - t2; real [i] += tl; imagfi] += t2; } z = ul*cl-u2*c2; u2 = ul * c2 + u2 * cl; ul = z ; } c2 = sqrt( ( l.Of - cl ) / 2.Of ); cl = sqrt( ( l.Of + cl ) / 2.Of ); } } Как видно из листинга 13.3, преобразование FFT, хотя и достойно звания быстрого, тре- бует все же большого количества вычислений. Применительно к элементу водной мозаики нам нужно выполнить FFT в двух измерениях: один раз - по каждой строке по горизонтали и другой - по каждому столбцу исходных данных по вертикали. Это дает нам окончательный набор данных, являющихся конгломератом всей информации о волнах в двухмерной сетке. Вместо попыток производить все вычисления и обновлять наш динамический буфер вершин, записывая в него результат, в каждом кадре, мы можем немного слукавить, исполь- зуя линейную интерполяцию. Согласно этому методу, мы анимируем таблицу один раз каждые п тиков. В течение периода, который предшествует очередному обновлению-анима- ции, мы будем проделывать все необходимые FFT-операции и обновлять буфер вершин. Так мы распределим все расчеты по определенному числу тиков игры. Для гладкости изображе- ния заполним промежуток между прежним и обновленным набором вершин посредством
ОКЕАНСКИЕ ВОДЫ ________273 интерполяции. В итоге вода по-прежнему анимируется интерполяцией двух ранее созданных наборов вершин, хотя «за сценой» мы трудимся над порождением очередного такого набора. Выиграв дополнительное время, мы можем ввести второе преобразование FFT ч'—-—-S для получения нормалей к поверхностям, характеризующим геометрию водной среды, - решить задачу, которую до сих пор игнорировали. Для отыскания норма- лей к поверхностям в каждой точке на сетке мы можем применить тот самый FFT-метод, которым пользовались для построения набора смещений наших вершин по высоте. Подробности можно найти в примере исходного кода на прилагаемом компакт-диске. С учетом нововведений в процессе в целом опишем схему нашего обновления, используя код, представленный в листинге 13.4. И снова код вырван из собственного контекста. Все функции, вызываемые в операторе выбора, можно найти в классе cOceanManager в примере исходного кода. Счетчик игровых тиков в листинге 13.4 служит для построения цикла по шагам ани- мации. За время тика игры он обновляется один раз. Каждый раз при вызове функции обновления в процессе анимации делается очередной шаг, и счетчик возрастает на еди- ницу. При достижении последнего шага результаты заносятся в один из двух буферов вершин. В каждом кадре интерполяционное значение заменяется новой скалярной величиной, предназначенной для плавного перехода между двумя буферами вершин вершинного шейдера. На экране наша периодическая анимация меша водной поверхности дает непрерывно анимированный результат. ЛИСТИНГ 13.4. Временное распределение процесса обновления водной поверхности океана enum eProcessingStages { k_animateHeight = О, k_animateNormal, k_heightFFTv, k_heightFFTh, k_normalFFTv, k_normalFFTh, k_uploadBuf fer, k_rotateBuffers, k_total_process_stages, }; void cOceanManager::update() { debug_assert( m_tickCounter >= k_animateHeight, "invalid tick counter"); // "счетчик тиков неверен" debug_assert(
274 Глава 13 m_tickCounter < k_total_process_stages, "invalid tick counter"); switch(m_tickCounter) { case k_animateHeight: m_fTime = applicationTimer.elapsedTime 0*0.15f; animateHeightTable(); break; case k_animateNormal: animateNormalTable() ; break; case k_heightFFTv: verticalFFT(m_colH ); break; case k_heightFFTh: horizontalFFT(m_colH ); break; case k_normalFFTv: verticalFFT(m_colN ); break; case k_normalFFTh: horizontalFFT(m_colN ); break; case k_uploadBuffer: fillVertexBuffer( m_pAnimBuffer[m_activeBuffer]) ; break; default: ++m_activeBuffer ; if (m_activeBuffer >= k_total_water_meshes) { m_activeBuffer = 0; } break; }; // найдем коэффициент интерполяции // ныне используемых вершин m_fInterpolation = //O.Of; (float) (m_tickCounter+l) /(float)(k_total_process_stages);
ОКЕАНСКИЕ ВОДЫ 275 ++m_tickCounter; if (m_tickCounter == k_total_process_stages) { m_tickCounter = k_animateHeight; m_fInterpolation = O.Of; } } Рендеринг воды Теперь, когда меш, представляющий океанские воды, реально анимируется на протя- жении серии кадров, мы можем узнать о текстуризации и освещении водной поверхности. Первый шаг - определить основной цвет воды. Здесь мы сделаем еще одно приближение, в основу которого будут положены наблюдения. Обобщая ситуацию в целом, можно ска- зать, что цвет воды в океане меняется в зависимости от угла зрения. При взгляде «сквозь» (на гребень волны) океан принимает зеленоватый оттенок. При взгляде прямо в толщу воды ее цвет становится темно-синим. Первое действие нашего вершинного шейдера - найти угол зрения и применить его в интерполяции двух постоянных значений цвета. Далее нам нужно поработать с водной поверхностью. Помимо анимированных норма- лей к поверхностям для имитации слабых поверхностных волн мы будем пользоваться картой нормалей к поверхности водной среды. И те и другие надо учитывать при вычис- лении итоговой добавки к отраженному свету на водной поверхности. Как и при наложе- нии на ландшафт текстуры рельефа, мы вычислим в каждой вершине бинормаль и вектор касательной, после чего используем их для переноса вектора освещения в пространство текстуры. Кроме того, найдем вектор половинного угла зеркального отражения и тоже переведем его в пространство текстуры. В пиксельном шейдере найдем скалярные произведения того и другого вектора на взятый из текстуры рельефа вектор нормали к поверхности. В случае с рассеянным светом получившийся световой вектор послужит для коррекции цвета воды после интерпо- ляции. В зеркальном случае мы возведем результат в зеркальную степень, после чего внесем в него поправку на текущий цвет солнца. Это придаст поверхности воды видозави- симую окраску в рассеянном свете, а также добавит сверкающих «зайчиков», вызванных отражением лучей солнца. Чтобы еще улучшить наш метод, мы анимируем текстурные координаты карты норма- лей. Базовый адрес текстуры найдем, если применим к позиции вершины в мире простой масштабный коэффициент. Он будет смещаться на горизонтальные составляющие норма- лей к поверхности для достижения волнообразного движения, поднимающего и опус- кающего гребень каждой волны. Для получения же дополнительного движения мелких
276 Глава 13 волн мы дважды считаем карту нормалей при разных масштабах. Первое считывание послужит для получения результата в рассеянном, второе - в зеркальном свете. Итог - разумная аппроксимация светонепроницаемой водной поверхности. Полный текст файла эффектов, который используется для порождения вод нашего океана, показан в листинге 13.5. Отображает нашу новую «океаническую» систему демонстрационная программа chapterl3_demo0 на прилагаемом компакт-диске. Даль- нейшие детали реализации можно обнаружить в исходном коде этого приложения. ЛИСТИНГ 13.5. Файл зффекюв для воды в океане // // Шейдер океанской воды // #include "light_scattering_constants.h" // преобразования fIoat4x4 mWorldViewProj: WORLDVIEWPROJECTION; float4 posOffset : posScaleOffset = {l.Of, O.Of, O.Of, O.Of}; float4 vCameraPos: worldcamerapos ; float4 sun_color: suncolor = {0.578f,0.578f,0.578f, 0.Of} ; float4 sun_vec: sunvector = {0.578f,0.578f,0.578f,0.0f}; float4 xAxis = {l.Of, O.Of, O.Of, O.Of}; float4 vOne = {l.Of, l.Of, l.Of, 0 . Of}; float4 vHalf = {0.5f, 0.5f, 0.5f, O.Of}; float3 waterColorO = {0.15f, 0.4f, 0.5f}; floats waterColorl = {O.lf, 0.15f, 0.3f}; texture texO : TEXTURE; struct VS_INPUT { float2 Pos : POSITION; float ZPosO : POSITION1; floats NormO : NORMALO; float ZPosl : POSITIONS; floats Norml : NORMALI;
ОКЕАНСКИЕ ВОДЫ 277 }; struct VS_OUTPUT { float4 Pos : POSITION; floats Col : COLORO; floats TO : TEXCOORDO; floats Tl : TEXCOORD1; floats T2 : TEXCOORD2; floats ts : TEXCOORD3; }; VS_OUTPUT VS(VS_INPUT v) { VS_OUTPUT Out = (VS_OUTPUT)0; // сместим xy и интерполируем z // для получения позиций в мире float3 worldPos = floats( v.Pos.x+posOffset.z, v.Pos.y+posOffset.w, lerp(v.ZPosO, v.ZPosl, posOffset.x)); // преобразуем и выведем результат Out.Pos = mul(float4(worldPos, 1), mWorldViewProj); // интерполируем нормаль float2 nXY = lerp(v.NormO, v.Norml, posOffset.x); floats normal = normalize(floats(nXY, 24.0f)); // вычислим текстурные координаты, используя позицию // в мировой системе координат и нормаль для анимации floats uvBase = floats( worldPos.x*0.0 If, worldPos.y*0.0 If, O.Of) ; floats uvAnim = normal * O.lf; Out.TO = uvBase + uvAnim; Out.Tl = (uvBase * 0.5f) - uvAnim; // используя векторные произведения, // вычислим бинормаль и касательную floats tangent = xAxis.yzx * normal.zxy; tangent = (-normal.yzx * xAxis.zxy) + tangent;
278 Глава 13 float3 binormal = normal.yzx * tangent.zxy; binormal = (-tangent.yzx * normal.zxy) + binormal; // переведем направленный на солнце // вектор в пространство текстуры floats lightVec; lightVec.х = dot(sun_vec, binormal); lightVec.у = dot(sun_vec, tangent); lightVec.z = dot(sun_vec, normal); // нормируем вектор света // и выведем результат Out.T2 = normalize(lightVec); // вычислим вектор зрения floats camera_vec = vCameraPos - worldPos; float s = length(camera_vec); camera_vec = normalize(camera_vec); // переведем вектор зрения в пространство текстуры floats viewVec; viewVec.х = dot(camera_vec, binormal); viewVec.у = dot(camera_vec, tangent); viewVec.z = dot(camera_vec, normal); // нормируем вектор зрения viewVec = normalize(viewVec); // найдем вектор половинного угла floats half_angle_vec = (viewVec + lightVec)*0.5f; // нормируем вектор половинного // угла и выведем результат Out.ТЗ = normalize(half_angle_vec); '// интерполятор цвета - это скалярное // произведение вектора зрения на нормаль float cosTheta = saturate(dot(-camera_vec, normal)); Out.Col = lerp(waterColorO, waterColorl, cosTheta); return Out; } sampler LinearSampO = sampler_state {
ОКЕАНСКИЕ ВОДЫ 279 texture = <texO>; AddressU = wrap; AddressV = wrap; AddressW = wrap; MIPFILTER = LINEAR; MINFILTER = LINEAR; MAGFILTER = LINEAR; } ; float4 PS(VS_OUTPUT In) : COLOR { // скомпонуем текстуры рельефа float3 bumpO = (tex2D(LinearSampO, In.TO )-0.5f)*2.Of; floats bumpl = (tex2D(LinearSampO, In.Tl )-0.5f)*2.Of; floats bump = (bumpO+bumpl)*0.5f; // найдем основной цвет рассеянных лучей света floats baseColor = dot(bump, In.T2) * In.Col; // рассчитаем зеркальную составляющую float specFactor = dot(bump, In.T3); floats specColor = specFactor * specFactor * specFactor * specFactor * sun_color; // сведем их воедино и выведем результат return float4((baseColor+specColor), l.Of); } technique OceanWater_l_l { pass P0 { CULLMODE = CW; ZENABLE = TRUE; ZWRITEENABLE = TRUE; ZFUNC = LESSEQUAL; AlphaBlendEnable = false; AlphaTestEnable = false;
280 Глава 13 // шейдеры VertexShader = compile vs_l_l VS () ; PixelShader = compile ps_l_l PS () ; } I Конец пути Итак, океан окружает наш маленький остров, - пример создания среды завершен. Мы изучили множество разных методик создания и рендеринга ЗО-ландшафта, а также управления им с применением DirectX 9. Познакомились с многочисленными схемами управления ландшафтной геометрией, реализовали процедуры синтеза ландшафта, использовали воздушное освещение для дальнейшего улучшения изображения в целом. Помимо этого, есть еще много тем и вопросов, достойных включения в наш движок. Завершая эту последнюю главу книги, мы приглашаем своих читателей к дальнейшему освоению таких тем, используя описанный здесь движок как своего рода трамплин для воплощения новых идей рендеринга и управления геометрией. Для ознакомления же с материалами по вопросам ландшафтного рендеринга и последующей их проработки мы предлагаем внимательно изучить приложение D «Рекомендуемая литература». Литература [Tessendorf] Tessendorf, J. «Simulating Ocean Water». Siggraph 2001 Course notes (работа доступна по адресу http://homel.gte.net/tssndrf/index.html). [JensenGolias] Jensen, L. S., and R. Golias. «Deep-Water Animation and Rendering». Gama- sutra Article (работа доступна по адресу www.gamasutra.com/gdce/2001/jensen/jensen JJl.htm). [ShaderX] Isidoro, J., A. Vlachos, and C. Brennan. «Rendering Ocean Water». ShaderX, Wordware Publishing, Inc. 2002, p. 347-356. [Wenzel] Wenzel, C. «Ocean Scene” (работа доступна по адресу http://meshuggah.4fo.de/ OceanScene.htm).
ПРИЛОЖЕНИЕ A СЛУЖЕБНЫЕ КЛАССЫ Gaia х-- В основе методов рендеринга ландшафтов, изученных нами в предшест- -------' вующих главах, лежит фундаментальный набор служебных библиотек, реали- зующих базовые возможности приложения. Мы не стали нагружать первую часть книги вводными главами, поясняющими каждую из систем, а взамен предлагаем обзор всех ком- понентов здесь. Изучая исходный код на компакт-диске, вы можете обращаться к этому приложению за разъяснениями по поводу классов, которые вам будут встречаться. Контроль битовых флагов Нередко нам требуется сохранять группу логических значений в одном объекте. И хотя мы считаем булевы значения противоположностями типа «истина или ложь», в памяти они по-прежнему занимают все 32 бита данных. Почему? Самым эффективным с точки зрения хранения типом данных ПК является целый, поэтому логические значения, в конце концов, и расширяются до размеров целого типа. Это не так важно, если в своем коде вы используете булевы значения время от. вре- мени; когда же вы сохраняете как члены класса несколько таких значений, пространство, потраченное впустую, начинает накапливаться. Упомянутый целый тип скорее похож на класс-контейнер. Однако, поскольку это контейнер разрядов, мы интерпретируем его как простой числовой тип данных. Контейнерный класс, используемый для объединения булевых значений как битовых флагов, способен эффективнее расходовать память и дать возможность одновременно установить, сбросить и проверить содержимое нескольких подобных значений. Битовое поле - это по сути не что иное, как обычная числовая переменная, разряды которой используются как независимые один от другого флаги. Плюсом такого подхода является то, что мы можем производить действия над группами флагов, используя
282 ПРИЛОЖЕНИЕA поразрядные операции (AND, OR, NOT и т. д.) над основной (host) переменной. Минусом - то, что при этом мы ограничены размером той основной переменной, которую сами и выбираем. По соображениям простоты мы намерены работать лишь с распространенными типами данных. Это значит, что в выборе самого большого возможного значения основ- ной переменной мы ограничим себя 32-битным числом. В результате наш класс для работы с битовыми полями сможет хранить не более чем 32 двоичных разряда флагов, но притом даст возможность без особых усилий проверять их и манипулировать ими. Теперь вы можете сами рискнуть и ввести как базовый тип 64-разрядные числа либо рас- ширить класс так, чтобы он содержал множество основных переменных; мы же, дабы сохранить простоту класса, оставим в силе ограничение на 32-разрядные флаги. Чтобы сделать код более удобным для чтения, нам нужно описать различия между битом и флагом. Отдельные программисты используют эти термины как синонимы, поэто- му мы разъясним смысл тех понятий, которые используются в движке. Если 32-разрядное значение трактуется как массив одноразрядных значений, то «бит» идентифицируется в массиве по индексу, «флаг» же является числовым значением массива в целом. Возьмем, к примеру, пустое битовое поле и установим бит с номером 3; теперь оно содержит значе- ние флага, которое равно 8 (1 000 в двоичной записи и есть десятичное 8). Биты могут пред- ставлять только отдельные булевы значения, тогда как флаги - представлять набор целиком. Пусть ранее созданный флаг равен 8, установшм в нем бит с номером 2. Теперь битовое поле содержит составной флаг, который равен 12 (в двоичной записи 1100) и го- ворит о том, что оба бита с номерами 2 и 3 имеют истинное значение. Это невероятно полезная вещь, поскольку теперь мы можем проверять множество битовых флагов, используя выполняемые над флаговыми значениями поразрядные опера- торы. Чтобы проверить, установлены ли три первых битовых флага в битовом поле, просто найдем значение [битовое_поле&7]. Если результат этого равен 7, то установлены вес три бита. Если результат не равен нулю, то установлен, по крайней мере, один из трех бит. Класс cBitField упрощает эти действия, предоставляя ряд функций-членов для уста- новки и проверки значений отдельных бит, а также составных флагов. Сам класс - это шаб- лон, поэтому он может использовать любой тип основных переменных, обозначенный через Т. typedef-конструкции с размером основных контейнеров флагов мы перечислим после описания класса. Основные фрагменты класса приведены в листинге А. 1. ЛИСТИНГ А.1. Основные фрагменты шаблонного класса cBitFlags из файла core\bit flags.h template <class T> class CBitFlags { public:
СЛУЖЕБНЫЕ КЛАССЫ Gaia ______________________________28i Т value; // Конструкторы... CBitFlags(); CBitFlags(Т data); CBitFlags(const cBitFlagsk Src) ; -cBitFlags(); // Операторы... cBitFlagsk operator=( const cBitFlags& Src) ; cBitFlagsSc operator=( T Src); operator T() {return(value); bool operator&(T test) ,- bool operator==(const cBitFlags& Src)const; bool operator!=(const cBitFlags& Src)const; // Методы модификации... void set(T settings); void clear(); void setFlags(T settings); void clearFlags(T settings); void setBitfint bit, bool setting=true); void clearBit(int bit); // Методы доступа... bool isEmpty()const; bool testBit(int bit)const; bool testFlags(T test)const; bool testAny(T test)const; int totalBits()const; int totalSet()const; }; // описания типов основных контейнеров флагов typedef cBitFlags<uint8> u8Flags; // 8 бит флагов typedef cBitFlags<uintl6> ulSFlags; // 16 бит флагов typedef cBitFlags<uint32> u32Flags; // 32 бита флагов // чтобы установить битовый флаг в значение "истина", // введем его в состав флага при помощи OR template <class Т> inline void cBitFlags<T>:zsetBit(int bit) { value |= (l«bit); } // чтобы установить битовый флаг в значение "ложь", // выполним операцию AND с маской, очищающей этот бит
284 ПРИЛОЖЕНИЕ A template <class T> inline void cBitFlags<T>::setBit(int bit) { value &= ~(l«bit); } // установка нескольких бит - это простая // дизъюнкция (OR) с переданным значением флага template <class Т> inline void cBitFlags<T>::setFlags(Т settings) { value |= settings; } // очистка нескольких бит - это простая конъюнкция // (AND) с инвертированным значением флага template <class Т> inline void cBitFlags<T>: .-clearFlags (Т settings) { value Sc= -settings; } // чтобы проверить единственный бит, мы сдвинем его // для получения флага, который и будет проверен template <class Т> inline bool cBitFlags<T>::testBit(int bit)const { return(value & (l«bit) ? true : false); } // вернем "истину", если установлен каждый переданный нам флаг template <class Т> inline bool cBitFlags<T>::testFlags(Т test)const { return((value & test) == test); } // вернем "истину", если установлен любой переданный нам флаг template <class Т> inline bool cBitFlags<T>::testAny(Т test)const { return(value & test ? true : false); }
СЛУЖЕБНЫЕ КЛАССЫ Gaia 285 Класс с единственным экземпляром Класс с единственным экземпляром (singleton class), коль скоро дело касается объек- тов класса, есть тип класса, способный существовать лишь в одном экземпляре. Обычно такие классы используются как классы-менеджеры для управления ресурсами всей систе- мы. Так, неплохими кандидатами на реализацию в единственном экземпляре являются объекты тех классов, которые управляют системной памятью, пространством текстур или воспроизведением звука. Наличие нескольких объектов управления такими ресурсами внесло бы в приложение дополнительный уровень сложности, которого можно легко избе- жать, используя классы с единственным экземпляром. Если мы хотим иметь всего один экземпляр класса, то почему бы просто не добавить описание класса и не создать единственный, глобальный его экземпляр? И действительно, это совершенно разумный способ действий в такой ситуации. Создание класса, содержа- щего статические функции-члены, тоже даст результат, реализуя глобальный доступ ко всем методам и членам и тем самым организуя единый интерфейс к определенному ресурсу системы. Есть целый ряд способов эмулировать действия классов с единствен- ным экземпляром; тем не менее каждый из них несет свой букет неприятностей, которых мы, однако, в состоянии избежать. Применение единственного, имеющего глобальное описание экземпляра объекта класса, в самом деле, реализует единый интерфейс управления данным ресурсом, однако не определен сам порядок создания такого объекта. Во многих случаях существует особая процедура создания системных менеджеров, которая должна быть соблюдена; так, менед- жеры памяти и файлов, возможно, должны быть созданы прежде, чем будет создан менед- жер, ответственный за текстуры. Применение объектов классов, которые описаны как глобальные экземпляры, делает управление этим порядком создания немного проблема- тичным. Необходимо, чтобы в какой-то момент времени при выполнении процедур запус- ка приложения мы создали дополнительные методы начальной установки для управления процессом создания. - Классы с единственным экземпляром имеют все преимущества альтернативных ч-~——методик при более высокой управляемости процессами конструкции и деструк- ции. По сути все, что дает указанный метод, — лишь способ гарантировать единственность существования экземпляра объекта в любой момент времени, а также глобальную доступность активного экземпляра всем, кому требуется с этим экземпляром общаться. Достижению цели служит простой шаблонный класс интерфейса, потомком которого может стать любой класс, стремящийся стать классом с единственным экземпляром. Простой шаблон класса с единст- венным экземпляром, используемый в библиотеке движка и находящийся в файле с о г е \ s in - gleton. h на прилагаемом компакт-диске, показан в листинге А.2.
286 ПРИЛОЖЕНИЕ A ЛИСТИНГ А.2. Шаблон класса с единственным экземпляром template <class Т> class Singleton { public: // класс с единственным экземпляром должен // содержать ссылку на управляемый им объект Singleton(T& rObject) { debug^assert(1s_plnstance, "only one instance allowed''); // "допустим только один экземпляр" s_plnstance = ^instance; } // деструктор класса -Singleton() { debug_assert(s_plnstance, "no instance available"); // "экземпляр недоступен" s_plnstance = 0; } // метод доступа к классу static Т& instance() { debug_assert(s_plnstance, "no instance available"); return (*s_plnstance); } private: // Данные... static T* s_plnstance; // Несуществующие функции... Singleton( const Singleton& Src); Singleton& operator^ const Singleton& Src); }; template «class T> T* Singleton<T>::s_plnstance = 0; Как было показано в листинге А.2, класс с единственным экземпляром - это действи- тельно не что иное, как способ управления указателем на объект класса. В силу допусти- мости наличия лишь одного подобного указателя класс с единственным экземпляром выступает гарантом того, что всем вызывающим подпрограммам доступен единственный,
СЛУЖЕБНЫЕ КЛАССЫ Gaia 287 уникальный экземпляр класса, которым он управляет. Объект управляемого класса пере- дается как параметр шаблона т при его построении. Затем указатель на управляемый класс заносится в статический член s_plnstance и возвращается функцией-членом instance () всем, кому он потребуется. Необходимые гарантии предоставляет макрос debug_assert, подражающий в работе стандартной функции assert (). Если при каком- то раскладе будет создан второй экземпляр такого объекта или кто-то попытается получить доступ к указателю на управляемый класс до его инициализации, то приложение будет завершено выводом окна с сообщением об ошибке (assertion). Чтобы создать класс, управляемый единственным экземпляром другого класса, просто породите класс с единственным экземпляром от шаблона и передайте управляе- мый класс как шаблонный параметр. Создав управляемый класс, отправьте ссылку на него в конструктор базового класса-шаблона с единственным экземпляром. Начиная с этого времени, указатель на управляемый класс будет храниться в базовом классе с единственным экземпляром, оставаясь глобально доступным посредством функции- члена instance (). Теперь управляемый класс в любое время гарантированно имеет только один активный экземпляр, а его интерфейс глобально доступен для приложения. Вот пример применения шаблона с единственным экземпляром. // пример класса, управляемого через шаблон с единственным экземпляром class MyClass : public Singleton<MyClass> { public: // когда MyClass будет создан, он должен будет построить // и базовый класс с единственным экземпляром MyClass() : Singleton(*this) {}; -MyClass() {}; void SomeFunction(); >; // глобальный доступ к управляемому классу возможен // через статическую функцию-член instance(). MyClass::instance().SomeFunction(); // также, чтобы сделать доступ к MyClass более удобным, //мы можем использовать препроцессорное описание вида #define MYCLASS MyClass::instance() // в дальнейшем это описание может использоваться так, // как будто оно является самим классом. MYCLASS.SomeFunction();
288 ПРИЛОЖЕНИЕ A В какой-то момент работы приложения единственный экземпляр управляемого класса должен быть создан, и это должно произойти до того, как будет сделано первое обращение к интерфейсу этого класса. Тот факт, создается ли экземпляр в глобальном пространстве, как локальная переменная или путем явных вызовов new и delete, не играет .никакой роли. Важным является время его создания и уничтожения, гарантирующее доступность ин- терфейса в момент обращения к таковому. Строки Текстовые строки - часто игнорируемый аспект компьютерных игр. Хотя в боль- шинстве 3D-nrp текст кажется не таким важным, он может иметь жизненно важное значе- ние в приключенческих и ролевых играх. Хороший класс строк полезен и при добавлении консоли для ввода от пользователя или системы создания основанных на тексте сце- нариев. В ходе решения задач этой книги для выполнения большей части работы исполь- зуется класс basic_string из библиотеки Standard Template Library (STL). Как под- тверждение нашего стремления к переносимости, все манипуляции с текстом в пределах движка осуществляются с учетом особенностей международного рынка. С целью прозрачной поддержки как восьми-, так и Unicode-совместимых, 16-битных наборов символов нами используется тип данных tchar. Его применение - простой способ поддержки отображения международного текста при помощи базовых функций работы с текстом, предоставляемых Win32 SDK. Библиотеку времени исполнения (runtime library) от Microsoft отличает немалая гибкость, достигаемая посредством базовых тексто- вых функций, автоматически переключающихся на однобайтные наборы символов ANSI (SBCS, single-byte character set) и локализующих по мере необходимости многобайтные наборы символов (MBCS, multibyte character set) или методы наборов символов Unicode. Реализуя поддержку желаемых символов средствами препроцессора, программист может выбрать набор символов, который будет использоваться в его приложении. По умолчанию движок настроен на работу со стандартным 8-битным набором символов ANSI. Чтобы изменить эту настройку, просто добавьте в установки проекта препроцес- сорные описания UNICODE или____MBCS. Тем самым вы перестроите тип данных tchar на нужное количество бит и переключите все базовые текстовые функции на соответст- венные специальные их аналоги. Более подробную информацию о базовых текстовых функциях можно найти в документации по Win32 SDK или узнать, ознакомившись с заго- ловочным файлом tchar. h, поставляемым с библиотекой разработчика для Win32. Чтобы парадигма универсального текста смогла работать, необходимо тщательно спланировать статический текст. Весь статический текст, предназначенный для вывода или применения в функциях манипуляций со строками, должен быть создан при помощи
СЛУЖЕБНЫЕ КЛАССЫ Gaia 289 макроса _text, определяющего требуемый набор символов. Так, чтобы описать статиче- скую строку текста, запишем: tchar my_string[] = _text("Some Text"); // "Пример текста" Если имя -UNICODE описано, такое макроопределение дает возможность записи статичес- ких строк в формате «широких» символов (16 бит на символ вместо обычных 8), сохраняя их совместимость с основанными на Unicode функциями работы с текстом. Если имя —UNICODE не описано, такой макрос не дает никакого эффекта, и строка остается в состоянии по умолчанию. Строки, которые встречаются в связанном с приложением файле ресурсов, преобразо- ваний не требуют. По умолчанию строки ресурсов по соображениям переносимости кодируются в формат Unicode. Когда же для загрузки строки ресурса в буфер tchar служит \¥ш32-функция Loadstring, необходимое преобразование наборов символов происходит автоматически. В итоге, если весь статический текст игры находится в файле ресурсов или описан в коде с применением макроса _text, достигнуть соответствия меж- дународным наборам символов становится так же легко, как перекомпилировать код с именами UNICODE или MBCS. х- Контейнером, созданным для хранения строковых данных в нашей библиотеке Ч'~- движка, является класс cString. Он построен на базе класса строк STL basic_string. Имея в своем составе дополнительные операторы дописывания в конец строки и равенства для записи выражений сравнения, он также имитирует ряд функцио- нальных возможностей MFC-класса CString. Кроме того, этот контейнер содержит функции-члены для загрузки строк из ресурсов, форматирования текста и сравнения строк независимо от регистра. Подобно базовым текстовым функциям, при помощи тех же описаний средствами препроцессора cString можно откомпилировать с поддержкой ANSI- или Unicode-символов. Код класса cString можно найти на прилагаемом компакт- диске в заголовочном файле core\string. h; базовый интерфейс показан в листинге А.З. ЛИСТИНГ А.З. Контейнерный класс cString, порожденный от STL-реализации basic_s tring class cString; public TEXT_BASE—CLASS { public: // Конструкторы... inline cString(){}; cString(const cString& rhs); explicit cString(const TEXT—BASE—CLASS& rhs); explicit cString(tchar c); cString(const tchar *s, ...); -cString(){}; // прямое приведение строк к указателю на tchar 10 - 1839
290 ПРИЛОЖЕНИЕ A inline operator const tchar*((const; // копирование данных из разных типов источников cString& operator=(const cString& rhs) ; cString& operator(const TEXT_BASE_CLASS& rhs) ; cString& operator;(const tchar *s) ; cString& operator=(tchar c) ; // дописывание данных из различных источников cStringb operator+=(const cString& rhs); cString& operator+=(const TEXT_BASE_CLASS& rhs) ; cString& operator+=(const tchar *s) ; cString& operator!;(tchar c); // проверка на равенство и неравенство (с учетом регистра) bool operator==(const cString& rhs); bool operator!=(const cString& rhs); // проверка без учета регистра bool compareNoCase(const cStringk rhs); // оператор, позволяющий пользоваться cString // как ключевым значением STL-карты bool operator<(const cString& rhs); // установка формата строки по дополнительным параметрам void cdecl format(const tchar* text, ...); void cdecl format(const tchar* text, va_list arglist); }; По тексту листинга А.З можно заметить, что интерфейс cString весьма ограничен. В целом он не делает почти ничего, кроме введения нескольких дополнительных функций, слегка упрощающих работу со строками. Он порожден от класса STL basic_string, поэтому наследует от STL-интерфейса большую часть основных строко- вых функций. Так, интерфейс базового класса реализует такие операции, как поиск под- строк, определение длины строки и очистка. Важным соображением, о котором следует помнить, работая с базовым STL-классом, является то, что, как и MFC-класс CString, класс basic_string сам выделяет память, если это необходимо. Применение функций манипуляций со строками может привести к большому количеству неявных операций выделения памяти и копирования ее содержимого, особенно если строки будут расти в размере. Чтобы частично решить эту проблему, мы отработаем у себя привычку использовать функцию reserve, входящую в класс basic_string. Функция reserve дает возможность выделить участок памяти заранее, «бронируя» рабочее место, в котором все последующие операции дописывания в конец строки смогут разместить новые фрагменты последней. Всякий раз, когда мы сможем оценить итоговую длину строки, такое предварительное резервирование пространства памяти позволит про- изводить над строкой целый ряд действий, не выполняя ненужных операций над памятью.
СЛУЖЕБНЫЕ КЛАССЫ Gaia 291 Данные о системе Определенные версии игрового движка оптимизированы под конкретный процессор и, к примеру, содержат инструкции SSE (Intel Streaming S1MD Extensions). Команды такого рода работают только на тех процессорах, которые их поддерживают. Спрашивать же пользователей о том, процессор какого типа у них установлен, - не самый надежный способ решения этой проблемы; поэтому, чтобы определить, какие наборы инструкций поддерживает центральный процессор, мы вынуждены послать запрос ему самому. И хотя так делать нехорошо, мы наведем справки и об операционной системе, и о доступной сис- темной памяти, которые вкупе могут повлиять на выбор того приложения, которое мы запустим, или наборов данных, которые изъявим желание загрузить. К примеру, мы можем создать особую версию исполнимого модуля игры под Microsoft Windows ХР с применением набора инструкций SIMD, а также еще одну версию, менее зависимую от конкретных ОС или ЦП. Тогда, чтобы запросить тип опера- ционной системы, а также процессора, мы будем использовать отдельное приложение, после чего загрузим оптимальную версию исполнимого модуля. И хотя конечный пользо- ватель будет располагать множеством исполнимых версий игры, загрузчик гарантирует выбор наилучшей программы для данной системы. Кроме того, сами игровые модули могут определять объем доступной системной памяти и загружать нужный набор данных игры. На машинах с ограниченной памятью можно загружать более мелкие текстуры и геометрические модели. Если же в наличии есть больше места, то загружаются более крупные, лучше проработанные в деталях наборы данных. Это помогает гарантировать игроку самые лучшие впечатления. Данный метод можно, хотя и с небольшим риском, развить так, чтобы на перспективных процессорах игра могла происходить в расширенном, усовершенствованном режиме. Так, пожалуй, существуют программные эффекты, которые мы не так часто можем позволить себе на имеющейся в наличии технике. Когда же мы обнаружим более мощный процессор, то сможем дать своей игре возможность осуществлять такие действия чаще, тем самым делая ее масштабируемой на будущей аппаратуре. Представьте себе, например, что в над- писи на экране используются текстуры с анимацией, загруженные из видеофайла опреде- ленного типа. На современной аппаратуре мы ограничиваем воспроизведение 10 либо 12 кадрами в секунду. Это дает нам немало времени на декодирование на ЦП очередного кадра, прежде чем тот понадобится, однако в итоге из-за невысокой скорости воспроизведе- ния анимация немного «дрожит». Тем не менее помимо версии на 10 кадров в секунду, которая используется по умолчанию, мы выпустим игру в версии, где видеоряд на текстуре анимирован со скоростью 30 и 60 кадров в секунду. Как только в будущем игра обнаружит сверхбыстрый процессор, она сможет выбрать один из более совершенных видеофайлов, тем самым повысив качество игры на новом процессоре. ю*
292 ПРИЛОЖЕНИЕA Чтобы запросить сведения о системе, на которой запущено приложение, опишем класс cSystemData для сбора информации и хранения ее в удобном для доступа виде. В этом классе будет содержаться такая информация, как тип процессора, наличная опера- ционная система и доступная системная память, а вместе с ними и примерная скорость процессора, установленного в системе. Эти данные позволят определить нужную версию исполнимого игрового модуля и соответствующий набор данных, которые будут загру- жены. Для сбора искомой информации класс cSystemData содержит функцию query- Systemlnformation, исходный код которой показан в листинге А.4. Первый шаг - определение объема имеющейся в машине системной памяти, а также объема памяти, в данный момент доступного нашему приложению. И хотя системная память, как можно было бы утверждать, благодаря системам виртуальной памяти становит- ся почти бесконечной, производительность все же серьезно упадет, если фрагменты данных вашего приложения будут выгружены в файл, расположенный на жестком диске. По этой причине лучше всегда знать, какой объем системного ОЗУ доступен для нашей работы. Win32 SDK содержит функцию, которая выдает все, что нам может потребоваться знать о памяти, имеющейся в системе, - GlobaIMemoryStatus . Эта функция заполняет информацией о системе и виртуальной памяти на данной машине структуру memorysta- tus. Структура содержит данные, которые приведены ниже. В функции querySystem- Inf ormation мы будем запрашивать эту информацию у системы и сохранять то, что нам нужно, в классе cSystemData . typedef struct _MEMORYSTATUS { DWORD dwLength; DWORD dwMemoryLoad; DWORD dwTotalPhys; DWORD dwAvailPhys; DWORD dwTotalPageFile; DWORD dwAvailPageFile; DWORD dwTotalVirtual; DWORD dwAvailVirtual ; // состояние памяти // размер, sizeof (MEMORYSTATUS) // процент используемой памяти // физическая память, байт // свободно в физической памяти, байт // страничный файл, байт // свободно в страничном файле, байт // адресное пространство user, байт // свободно в user, байт } MEMORYSTATUS, *LPMEMORYSTATUS; Полями, интересующими нас больше всего, являются dwTotalPhys и dwAvailPhys, которые содержат информацию об общем объеме системной памяти, имеющейся на данном компьютере, а также о том, какая часть этой памяти доступна нам для работы. Если общий объем физической памяти на данной машине не достигает того, что требует наша игра, мы вынуждены сообщить об этом пользователю и завершить приложение. Если же общий объем памяти удовлетворяет ограничениям, но требуемое количество памяти сейчас не доступно, нам нужно предупредить пользователя о том, что производи- тельность нашей игры будет страдать, если он не закроет внешние приложения, чтобы освободить больше места.
СЛУЖЕБНЫЕ КЛАССЫ Gaia 293 Следующий шаг состоит в том, чтобы определить, какая операционная система уста- новлена на машине. И хотя код, который работает на одних, но не работает на других версиях Windows, пишется достаточно редко, есть несколько ситуаций, когда это может произойти. В состав Windows NT были введены несколько функций, которых нет в Win- dows 95. В основном это расширенные версии существующих функций, такие, как Find- FirstFileEx и GetDiskFreeSpaceEx, однако под NT имеется более развитая поддержка мно- гопоточных приложений, чего не было в ранних версиях Windows. Если в своем приложе- нии вы хотите использовать подобные функции, то должны убедиться в том, что распола- гаете нужной операционной системой. То, что мы хотим выяснить, нам снова сообщает простой вызов функции. Структуру, содержащую данные об операционной системе, заполняет функция GetVersionEx. Наша забота - перевести занесенные в эту структуру номера версии и подверсии в дан- ные, которые мы сможем использовать. Для себя мы создадим тип-перечисление извест- ных версий Windows со следующими элементами: enum WINDOWS—VERSIONS { UNKNOWN =0, WINDOWS_95, WINDOWS—95_SR2, WINDOWS_98, WINDOWS—98_SR2, WINDOWS—ME, WINDOWS—NT, WINDOWS—2K, WINDOWS—XP, // последнее значение - для // версий windows, выпуск // которых еще не произошел WINDOWS-FUTURE }; Элементы перечисления упорядочены по возрастанию, так что мы сможем использовать этот порядок себе на пользу. Наше предположение состоит в том, что каждая версия обратно совместима со всеми младшими версиями, приведенными в перечислении. WINDOWS_98 обратно совместима с WINDOWS—95; WINDOWS—NT обратно совместима с WINDOWS—98 и т. д. Если мы знаем, какое из перечисленных значений отражает наши минимальные потреб- ности, то можем быстро определить, отвечает ли данная ОС нашим нуждам. Если наши условия не соблюдаются, мы будем вынуждены проинформировать пользователя об этом и завершить приложение. Номера версий и подверсий, полученные от GetVersionEx, преобразует в номера версий из нашего перечисления приведенная в листинге А.4 функция querysysteminformation.
294 ПРИЛОЖЕНИЕA Сведений об идентификации центрального процессора, который установлен в систе- ме, есть достаточно много. Реализация этого теста требует применения инструкций на языке ассемблера, которые запрашивают эту информацию у ЦП. Большая часть такой информации раскрыта изготовителями процессоров, приводится она и в текстах, подоб- ных статье Роба Вятта (Rob Wyatt) об идентификации процессоров, опубликованной в июле 1999 г. на Gamasutra.com [Wyatt], Эти работы содержат более подробную информа- цию о том, как запросить процессор с целью его опознать. Вместо того чтобы идентифицировать сам процессор, нам нужно определить лишь то, какой код он поддерживает. Если в нашей программе есть код, ориентированный на кон- кретный процессор, лучше всего прямо проверить поддержку этого кода, а не модель ЦП и его марку. Набор инструкций SSE корпорации Intel поддерживают микросхемы многих производителей. Теперь недостаточно определять, является ли ЦП подлинным Intel Pen- tium III (или выше). Набор команд SSE поддерживают и более новые процессоры, такие, как AMD Athlon ХР, стало быть, они будут поддерживать и тот код, который мы намерены запускать. Будущие процессоры также, возможно, будут поддерживать эти инструкции, и оттого данные об изготовителе во многом теряют свое значение. Главной в идентификации возможностей ЦП является ассемблерная инструкция CPUID. Она является «ключом» к опросу процессора и может использоваться для получе- ния его имени, перечня возможностей и в ряде случаев - серийного номера. В нашем случае все, что нам нужно, - это набор возможностей, однако сначала мы должны опреде- лить, поддерживается ли сама инструкция CPUID. Чтобы определить наличие поддержки CPUID, проверим 21-й разряд регистра EFLAGS. Если этот флаг может изменяться программно, поддержка инструкции CPUID существует. Этот же тест должен быть справедлив и для других производителей аппара- туры, поддерживающих в своих продуктах инструкцию CPUID. Как видно из текста функции cpu_supports_cpuid в листинге А.5, всю работу выполняет небольшая ассемблерная вставка, извлекающая содержимое регистра EFLAGS по команде PUSHFD. Затем функция переключает бит в позиции 21, после чего использует инструкцию POPFD для занесения исправленного значения обратно в регистр EFLAGS. Далее,"второй инструкцией pushfd она извлекает содержимое регистра eflags и про- веряет, действительно ли значение бита ID равно тому, что было установлено нами. Если да, то этим мы доказали, что 21-й разряд регистра eflags допускает свою перезапись; инструкция cpuid доступна. Как только мы обнаружим поддержку CPUID, мы можем продолжить проверку наличия необходимых нам свойств. Функция cpuid может решать множество задач и возвращать разные наборы данных. В своих целях нам нужно узнать, какие возможности имеет процес- сор. Для этого до вызова CPUID загрузим в регистр еах единицу. Задание этого значения в еах потребует от CPUID выдать набор битовых флагов, отражающих конкретные возможности
СЛУЖЕБНЫЕ КЛАССЫ Gaia 295 ЦП и размещаемых в регистре edx. Копируя содержимое edx в собственную 32-разрядную переменную флагов, мы сможем определить возможности, которые реализует процессор. Эта операция показана в функции get_cpu_f eature_f lags из листинга А.5. 32 флага, выдавае- мые инструкцией CPUID, приведены в приложении С «Руководство программиста». Флаги, которые нам нужно проверить, суть флаги, отражающие возможности, которыми мы намерены пользоваться. К ним относятся биты с номерами 25 и 26, которые указывают на поддержку процессором наборов инструкций Intel SSE и SSE2. Впрочем, для этих двух функций опроса процессора будет мало. Даже если эти два бита процес- сором установлены, нам все равно необходимо проверить, допустимы ли эти команды операционной системой. Мы сделаем это «ленивым образом», попытавшись вызвать такие функции и отследив исключения, которые укажут на неудачу. Если эти вызовы при- ведут к исключениям, операционная система не поддерживает подобные расширения. Две функции для выяснения поддержки SSE и SSE2, os_supports_sse_instructions ё os_supports_sse2_instructions, содержатся в листинге А.5. Последнее, что мы хотим отследить, - это примерная скорость процессора. Знать ее не мешает, если мы намерены настроить отдельные зависимые от процессора функции нашей игры, такие, как операции искусственного интеллекта или эффекты неаппаратного рендеринга. К сожалению, сбор сведений о реальной скорости ЦП не является точной наукой. Пользуясь собственным встроенным счетчиком на процессоре, мы можем точно определить, сколько тактов ЦП уложилось в данный отрезок времени, но даже эта информация не является по-настоящему верной. Чтобы прочитать количество циклов ЦП за истекшее время, воспользуемся ассемб- лерной инструкцией rdtsc. Эта инструкция, имя которой означает «чтение счетчика отметок реального времени» (Read Time Stamp Counter), заполняет два 32-битных регистра, перегружая в них со счетчика в общей сложности 64 разряда данных. Содержи- мое счетчика прирастает со скоростью работы процессора. Так, процессор с частотой 1 ГГц инкрементирует счетчик примерно 1 000 000 раз в секунду. Пользуясь этой информацией, мы можем дважды прочитать содержимое счетчика и посмотреть, насколь- ко оно со временем возросло. Это и даст нам грубую оценку скорости ЦП. Причина, по которой эта оценка является грубрй, состоит в том, что чтение содержи- мого счетчика на процессоре мы осуществляем во времени, а любой метод, которым мы пользуемся для определения хода времени в реальном масштабе, отчасти неточен. Еще одна причина - в том, что может меняться сама скорость ЦП. Так происходит в случае со многими современными мобильными процессорами, допускающими понижение скорости при низкой загрузке. Это - один из способов того, как мобильные процессоры экономят энергию. В результате наша оценка скорости процессора полезна лишь как ориентир, а не точное значение показателя.
296 ПРИЛОЖЕНИЕA Действие базового теста оценки скорости процессора показано в листинге А.6. Эта функция использует также класс cTimer, который определяет время, фактически истек- шее по ходу считывания пары значений при помощи инструкции rdtsc. Тогда расчет скорости процессора становится делом простого деления числа тактов ЦП за истекший период на время, потраченное на сбор данных. Это дает оценку количества тактов в секун- ду, которая должна быть примерно равна скорости процессора как таковой. ЛИСТИНГ А.4. Сбор информации о системе void eSysteminfo::querySystemlnformation() { MEMORYSTATUS MemStatus; OSVERSIONINFO OSVersion; // определим состояние памяти MemStatus.dwLength = sizeof (MemStatus); GlobalMemoryStatus(&MemStatus); // получим данные о версии ОС OSVersion.dwOSVersionlnfoSize = sizeof(OSVersion); GetVersionEx(&OSVersion); // заполним наши собственные поля данных m_physicalMemory= MemStatus.dwTotalPhys; m_totalMemory= MemStatus.dwAvailPhys + MemStatus.dwAvailPageFile; // // Выясним, какая ОС запущена // if (OSVersion.dwPlatformld== VER_PLATFORM_WIN3 2_WINDOWS) { m_osVersion.Build= LOWORD(OSVersion.dwBuildNumber); m_platform=WIND0WS_95; if (m_osVersion.MinorVersion==0 && m_osVersion.Build>950) { m_platform=WINDOWS_95_SR2; } else if (m_osVersion.MinorVersion==10)
СЛУЖЕБНЫЕ КЛАССЫ Gaia 297 { m_platform=WINDOWS_98; } else if (m_osVersion.MinorVersion>10) { m_p1a t f о rm=WINDOWS_ME; } } else if (OSVersion.dwPlatformId== VER_PLATF0RM_WIN3 2_NT) { m_osVersion.Build =OSVersion.dwBuildNumber; if (m_osVersion.MajorVersion<4) { m_p1a t f о rm=WINDOWS_NT; } else if (m_osVersion.MajorVersion == 4) { m_plat form=WIND0WS_2К; } else if (m_osVersion.Majorversion == 5) { m_platform=WINDOWS_XP; } else { m_p1a t fо rm=WINDOWS_FUTURE; } } else { m_platform =UNKNOWN; m_osVersion.Build =OSVersion.dwBuildNumber; } // // Проверим дополнительные возможности CPU // m_cpuFlags = get_processor_flags();
298 ПРИЛОЖЕНИЕA Листинг А.5. Ассемблерные функции определения возможностей процессора bool cpu_supports_cpuid() { uint32 result=0; _asm{ pushfd pop eax // Получим исходный EFLAGS mov ecx, eax xor eax, 200000h // Перебросим ID-бит в EFLAGS push eax // Сохраним новое значение EFLAGS popfd // Заменим текущий EFLAGS pushfd // Получим новый EFLAGS pop eax // Сохраним новый EFLAGS в ЕАХ xor eax, ecx JZ THE_END // Неудача - ИНСТРУКЦИИ CPUID НЕТ // Процессор поддерживает инструкцию CPUID. mov result,1 THE_END: } return (result ? true:false); } u32Flags get_cpu_feature_flags() { u32Flags result=0; if(cpu_supports_cpuid()) { _asm { pushad mov eax,1 ; выберем флаги возможностей epuid mov result,edx popad } } return (result); } // выясним, допустимы ли расширения Intel SSE в данной ОС bool os_supports_sse_instructions() { try
СЛУЖЕБНЫЕ КЛАССЫ Gaia 299 { ______asm { pushad; // попробуем SSE-вызов orps xmml,xmml; popad; } ) __except(1) { return(false); } return(true); } // выясним, допустимы ли расширения Intel SSE2 в данной ОС bool os_supports_sse2_instructions О { __try { ______a fem { pushad; // попробуем SSE2-вызов paddq xmml,; xmm2 popad; } } __except(1) { return(false); } return(true); } u32Flags get_processor_flags() { u32Flags result=get_cpu_feature_flags(); // если флаги SSE установлены, устроим // вторичную проверку возможностей ОС if (result.testBit(25)) {
300 ПРИЛОЖЕНИЕ A if (!os_supports_sse_instructions()) { result.clearBit(25); } else { if (result.testBit(26)) { if (!os_supports_sse2_instructions()) { result.clearBit(26); } } } } return result; } ЛИСТИНГ A. 6. Ассемблерные функции определения скорости процессора void cSystemlnfо::readCPUCounter(uint64 *pCounter) { _asm { RDTSC mov edi, pCounter mov DWORD PTR [edi], eax mov DWORD PTR [edi+4], edx }; } void eSysteminfo::computeProcessorSpeed() { uint64 startTime, endTime; cTimer localTimer; // запустим таймер localTimer.start(); // прочтем счетчик ЦП readCPUCounter(&startTime); // слегка выждем Sleep(100); // прочтем счетчик ЦП еще раз readCPUCounter(&endTime); // остановим часы
СЛУЖЕБНЫЕ КЛАССЫ Gaia 301 localTimer,stop(); // найдем скорость процессора // в тиках в миллисекунду uint64 sampleDelta = endTime - startTime; uint32 elapsedMilliseconds = localTimer.elapsedMilliseconds(); m_processorSpeed = (uint32)sampleDelta/elapsedMilliseconds; } Утверждения, предупреждения и комментарии Важнейшим для любого .комплекта отладочных инструментов является скромный макрос обработки утверждений assert. Утверждения - это полезное средство проверки пред- положений в пределах кода и обнаружения ошибок прежде, чем те станут трудно находи- мыми сбоями.^ По сути, такие макроопределения берут некое условие и «панически» реа- гируют на то, что оно оказалось ложным. Паника эта отражается на экране в виде окна с со- общением, содержащим неверное утверждение. Для данного макроса assert (х==5) сообще- ние появится в том случае, если значение х во время обработки макроопределения assert не будет равно 5. Стандартная библиотека языка С содержит макрос assert, который любезно отлавливает ошибки, но не оправдывает ожиданий в двух ключевых аспектах своей работы. Во-первых, когда обычный макрос assert обнаруживает ошибку, он выдает весьма малоинформативное окно сообщений. Единственное, что он демонстрирует пользова- телю, - это условие, которое вызвало ошибку в макроопределении assert. На практике это может означать, что код assert (х==17) приведет к паническим настроениям, когда обнару- жит, что х!=17, и при этом отобразит в окне сообщений нечто загадочное типа «Assertion Failed! Expression: х==17» («Невыполнение утверждения! Выражение: х==17»), Такая ийформация, хотя и описывает факт, не очень-то и полезна. На деле единственная ситуация, в которой макрос assert действительно полезен, - это запуск приложения в среде отладки или при активном на данной машине отладчике Just-In- Time. Если то или другое имеется, окно с сообщением будет содержать кнопку, нажатие которой позволит проникнуть внутрь кода и даст возможность исследовать проблему со всех сторон. Именно здесь становится очевидным второй недостаток макроса - точка останова фактически находится в файле assert.с, где реализована функциональность assert. Коль скоро проект приложения обычно не содержит файла assert. с из стандартной библиотеки, то первое, что продемонстрирует Visual C++, - это файловый диалог, запраши- вающий расположение assert, с. Реальную строку кода, которая вызвала ошибку, можно
302 ______________________________ _ ПРИЛОЖЕНИЕ^ найти после отмены диалога поиска файла и перехода по стеку вызовов вверх. Согласимся, что эти дополнительные шаги на пути к корню проблемы - сущий пустяк, однако они пред- ставляют собой ту неприятность, которую мы можем с легкостью устранить. Задавшись целью выдавать более ценную информацию и допуская возможность ока- заться на нужной строке кода, содержащей проверяемое нами условие, следующим своим шагом мы положим изобрести замену стандартному макроопределению assert. К счастью, недостатки стандартного варианта assert в языке С вызвали недовольство и других про- граммистов, и нам уже доступен обширный список неплохих предложений по его замеще- нию. Как и во многих других областях программирования игр, всегда есть кто-то другой, пребывающий в поисках решения тех же проблем. Джон Роббинс (John Robbins), автор колонки «Bug Slayer» («Охотник за програм- мными сбоями») в Microsoft Systems Journal, написал об идее Super Assert в своей статье в феврале 1999 г. [Robbins]. Помимо других новшеств он ввел в окно выдачи сообщений полную трассировку стека. Располагая информацией о стеке вызовов, программист может логически заключить, что стало причиной ошибки, запуская приложение вне отладочных сред. Подобное распространено при бета-тестировании, во время которого тестер может и не иметь в своем распоряжении всей среды для отладки. Стив Рабин (Steve Rabin) написал статью, основанную на работе Роббинса и поме- щенную в книге Game Programming Gems [Rabin]. Его нововведения включали обогаще- ние отладочного вывода возможностями вставки в буфер обмена, позволяющими легко передавать текст ошибки и стек вызова по электронной почте или заносить в файл. Кроме того, Рабин предложил идею создания условных (conditional) уведомлений о невыполне- нии утверждений, позволяющих отключать конкретные утверждения при продолжении функционирования остальных. Каждая из этих идей великолепна, и мы воспользуемся ими в своей собственной замене assert. Наконец, Microsoft тоже реализовала в библиотеке DirectX ряд крайне полезных функ- ций. Так, DXGetErrorString9 () И DXGetErrorDescription9 () могут перевести коды ошибок DirectX и Win32 в удобные для восприятия строки. Пользуясь этими функциями, мы можем создать особый тип макроса assert для обнаружения ошибок, возникающих при сбое функций DirectX или Win32. Взяв на вооружение все эти идеи, мы можем создать набор своих макроопределений assert. Однако в отличие от Рабина и Роббинса, мы не станем использовать собственное окно диалога. Коль скоро мы хотим встроить механизм утверждений в статическую библиотеку ядра, мы лишены доступа к таким ресурсам приложения, как нестандартный шаблон диало- говых окон. Поэтому мы будем и дальше использовать вс троенное окно сообщений Abort - Retry - Fail (Стоп - Повтор - Сброс), предлагаемое Win32, хотя и немного не так, как это делает стандартное макроопределение assert.
СЛУЖЕБНЫЕ КЛАССЫ Gaia 303 Наш первый шаг - сформировать функцию, которая создаст и отобразит окно сообще- ний, после чего вернет выбранный пользователем вариант: отменить, повторить или игнорировать утверждение. В отличие от стандартного макроса, в нашем окне кнопки сохранят свое истинное значение. В то время как кнопка Retry (Повтор) стандартного assert служит для перехода в текст кода, наша будет подразумевать, что пользователь намерен продолжить работу программы. Кнопка Ignore (Игнорировать) будет использо- ваться для отклонения всех будущих случаев невыполнения условия проверяемого утверждения. По кнопке Abort (Стоп) пользователю будет задан вопрос, желает ли он завершить приложение или перейти в его код. Используя эти описания кнопок, мы получим для себя все желаемые возможности, но будем по-прежнему интуитивно приме- нять кнопки стандартного окна сообщений Abort - Retry - Fail. s' c Имея возможность отследить текущий стек вызовов и создать строку с его содержимым (код этих действий можно найти в core\stack_trace. срр на прилагаемом компакт-диске), мы можем теперь построить и свою функцию сообщения о невыполнении утверждений. В ее обязанности входит сбор всей необходимой информации, организация строки выдачи нарушения и демонстрации ее пользователю в окне сообщений Abort - Retry - Fail. Отклик пользователя на окно транслируется в значения перечислимого типа, который описывает дальнейшие действия. Функция при- ведена в листинге А.7. ЛИСТИНГ А.7. Выдача ошибок для пользователя // Выходные значения функции assert enum ERROR_RESULT { VR_IGNORE = О, VR_CONTINUE, VR_BREAKPOINT, VR_ABORT I; ERROR_RESULT displayError(const tchar* errorTitle, const tchar* errorText, const tchar* errorDescription, const tchar* fileName, int lineNumber) { const int NAME_SIZE = 255; tchar moduleName[NAME_SIZE]; // попытка получить имя модуля if (!GetModuleFileName(NULL, moduleName, NAME_SIZE))
ПРИЛОЖЕНИЕA _tcscpy(moduleName, _text ( "-(unknown application:*") ) ; // "-(неизвестное приложение:*" } // если трассировка стека разрешена, // то сформируем строку, которая // содержит информацию о стеке #ifdef _STACKTRACE const int STACK_STRING_SIZE = 255; tchar stackText[STACK_STRING_SIZE]; buildStackTrace(stackText, STACK_STRING_SIZE, 2); #else tchar stackText [] = _text ( "<stack trace disabled:*"); // "(трассировка стека запрещена:*" #endif // сформируем сверхдлинную строку, // содержащую все сообщение об ошибке const int MAX_BUFFER_SIZE = 1024; tchar buffer[MAX_BUFFER_SIZE]; int Size = _sntprintf(buffer, ИГНОРИРОВАТЬ MAX_BUFFER_SIZE, _text( "%s\n\n" \ "Program : %s\n" \ /* "Программа : " */ "File : %s\n" \ /* "Файл : " */ "Line : %d\n" \ /* "Строка : " */ "Error: %s\h" \ /* "Ошибка : " */ "Comment: %s\n" \ /* "Комментарий: " */ "\nStack:\n%s\n\n" \ /* "Стек: " */ "Abort to exit (or debug), "\ /* "СТОП для выхода (или отладки)" */ "Retry to continue,\n"\ /* "ПОВТОР для продолжения" */ "Ignore to disregard all occurrences'^ /* для */ " of this error\n"), /* отклонения всех случаев этой ошибки" */ errorTitle, moduleName, fileName, lineNumber, errorText, errorDescription, stackText ) ; // поместим копию сообщения в буфер обмена
СЛУЖЕБНЫЕ КЛАССЫ Gaia 305 if (OpenClipboard(NULL)) { uint32 bufferLength = _tcsclen(buffer); HGLOBAL hMem = GlobalAlloc(GHND|GMEM_DDESHARE, bufferLength+1); if (hMem) { uint8* pMem = (uint8*)GlobalLock(hMem); memcpytpMem, buffer, bufferLength); GlobalUnlock(hMem); EmptyClipboard(); SetClipboardData(CF_TEXT, hMem); } Closeclipboard(); } // найдем самое верхнее окно текущего приложения HWND hWndParent = GetActiveWindow ( ) ; if ( NULL 1= hWndParent ) { hWndParent = GetLastActivePopup ( hWndParent ) ; } // выведем окно с сообщением об ошибке int iRet = MessageBox(hWndParent, buffer, _text ( "ERROR NOTIFICATION..." ), /* "УВЕДОМ- ЛЕНИЕ ОБ ОШИБКЕ..*/ MB_TASKMODAL |MB_SETFOREGROUND I MB—ABORTRETRYIGNORE IMB—ICONERROR); // Выясним, что делать по завершении, if (iRet == IDRETRY) { // игнорируем эту ошибку и продолжаем return (VR_CONTINUE); } if (iRet == IDIGNORE) { // игнорируем эту ошибку и продолжаем, // никогда не останавливаясь на ней впредь return (VR—IGNORE); }
306 ПРИЛОЖЕНИЕA // Возвращаемым значением функции должно быть значение // IDABORT. Тем не менее, намерен ли пользователь войти // в отладчик или просто закрыть приложение? iRet = MessageBox .( hWndParent, "debug the last error?", /* "отладить место последней ошибки?" */ -text ( "DEBUG OR EXIT?" ), /* "ОТЛАДИТЬ ИЛИ ЗАКРЫТЬ?" */ MB_TASKMODAL |MB-SETFOREGROUND |MB_YESNO |MB_ICONQUESTION); if (iRet == IDYES) { // сообщим вызывающей подпрограмме об ост.анове // на текущей исполняемой строке кода return (VR-BREAKPOINT); } // необходим полный останов приложения ExitProcess ( (UINT)-1 ) ; return (VR—ABORT); } Располагая функцией displayError, мы можем построить макрос, который будет вызывать ее для выдачи утверждений, когда те будут нарушены. Он будет проверять пере- даваемое ему условие и вызывать displayError, когда то не будет соблюдено. Поместив скобки в тело самого макроса, мы так расширим код, чтобы создать локальную область видимости, в которой может быть расположена и статическая переменная. Она послужит -для принятия решения о том, следует ли сообщать пользователю о невыполнении утверждения. Если пользователь задумает отклонить все будущие ошибки, вызванные тем или иным утверждением, то внутренняя статическая переменная окажется установленной на предотвращение повторного вывода окна сообщений. Если пользователь выберет отладку данного кода, то будет возбуждено прерывание с но- мером 3, эквивалентное контрольной точке в коде для процессоров на базе платформы Intel. Строка _asm{int 3} побуждает отладчик, если он есть, войти в код именно в той строке, где содержится прерывание. Поскольку возбуждение прерывания встроено в макроопреде- ление, отладчик покажет строку кода, содержащую наше исходное условие формального утверждения. Исходный код макроса debug_asser t продемонстрирован в листинге А.8.
СЛУЖЕБНЫЕ КЛАССЫ Gaia 307 Листинг А.8. Управление выдачей сообщений о невыполнении утверждений при помощи макроса #define debug_assert(х, comment) {\ static bool _ignoreAssert = false;\ if (!_ignoreAssert && ! (x) ) \ {\ ERROR_RESULT _err_result = \ displayError(_test("debug assert!"),\ /* "отладка утверждения!" */ _text(#x), _text(comment), \ _____FILE______LINE_) ; \ if (_err_result == VR_IGNORE) \ {\ _ignoreAssert = true; \ }\ else if (_err_result == VR_BREAKPOINT)\ {\ _asm{int 3};\ }\ }} В отличие от стандартного макроса assert, наш debug_assert принимает как условие для проверки, так и строку комментария., Комментарий передается функции displayEr- ror, если условие утверждения (х) не выполняется. Это дает программисту возможность выдачи в окне сообщений осмысленной текстовой информации. Представьте, например, что у вас есть 32-разрядное битовое поле и вы хотите генерировать ошибку каждый раз, когда кто-то пытается запросить бит вне диапазона 0-31. Окно с сообщением при каждом получении неверного индекса вам выдаст следующая проверка условия: debug_assert(index>=0 && index<32, "invalid bit index requested"); // "запрошенный индекс бита неверен" Последняя возможность, которую требуется добавить, - это применение функций DirectX DXGetErrorString9 () и DXGetErrorDescription9 () для вывода удобных для восприятия строк по кодам ошибок DirectX и Win32. Для реализации этой функциональ- ности добавим вторую функцию преобразования кода ошибки в набор строк и передачи их функции displayError. Чтобы еще немного упростить применение этой функции при вызове с кодом ошибки, равным нулю, запрограммируем ее так, чтобы она самостоя- тельно вызывала GetLastError для получения кода последней известной ошибки. Это полезно для функций Win32, возвращающих нечто отличное от кода ошибки, но уста- навливающих внутренний код ошибки при своем сбое. Исходный код выдачи сообщений об ошибках и макрос, который им пользуется, показаны в листинге А.9.
308 ПРИЛОЖЕНИЕA ЛИСТИНГ А.9. Анализ кодов ошибок Win32 и DirectX и выдача их для пользователя ERROR_RESULT notifyError(uint32 errorCode, const tchar* fileName, int lineNumber) { // если код ошибки не передан, получим // последнюю из известных ошибок if (errorCode == 0) { errorCode = GetLastError(); } // воспользуемся DirectX для получения // строки и описания нашей ошибки. // Следующий код обрабатывает все известные // коды ошибок (HRESULT) DirectX, а также // коды ошибок Win32, с которыми обычно // имеет дело функция FormatMessage const tchar* pErrorString = DXGetErrorString9(errorCode); const tchar* pErrorDescription = DXGetErrorDescription9(errorCode); // передадим данные окну сообщений ERROR_RESULT result = displayError( _text("Error!"), /* "Ошибка!" */ pErrorString, pErrorDescription, fileName, lineNumber); // Поместим последнюю ошибку на прежнее место. SetLastError(errorCode); return(result); } #define debug_error(x) {\ static bool _ignoreError = false;\ if (LignoreError) \ {\ ERROR_RESULT _err_result = notifyError((x), \ ______FILE___, __LINE__) ; \ if Lerr.result == VR_IGNORE)\ {\ _ignoreError = true;\ }\ else if (_err_result == VR_BREAKPOINT)\ {\ _asm{int 3} ; \ }\ }}
СЛУЖЕБНЫЕ КЛАССЫ Gaia 309 Утверждения времени компиляции Утверждения, которые срабатывают во время работы приложения, крайне полезны, но иногда возникают условия, которые мы хотели бы отслеживать при компиляции (com- pile-time asserts). Обычно это «ловушки», помещаемые нами в текст для того, чтобы пой- мать себя на написании какого-то деструктивного кода. Утверждения, в которых мы про- веряем размер отдельного объекта, число конкретных объектов или иную критичную информацию, жизненно важны, чтобы держать нас самих под контролем. Один такой пример относится к битовым флагам,. Пусть мы создали объект cBitFlags с 8 битами информации для идентификации объекта игры, такого, как монстр. Помимо того, мы хотим отдельно идентифицировать каждый бит в наборе битовых флагов, исполь- зуя значения перечислимого типа. Таким образом, мы сможем проще запрашивать данные о монстре, чтобы узнать, что это за персонаж. Для этого мы могли бы создать перечисле- ние, подобное следующему: enum eBitFlaglndices { k_hasFangs =0, /* имеет клыки */ k_hasClaws, /* имеет когти */ k_smellsAwful, /* отвратительно пахнет */ . . и т . д. k_totalBitFlags, Если мы добавим восемь enum-значений, они, как можно заметить, будут автома- тически пронумерованы от 0 до 7, являясь превосходными индексными значениями, при помощи коих можно опрашивать битовые флаги объекта. Аналогично последнее значе- ние k__totalBitFlags будет содержать общее число битовых индексов в перечислении - восемь. Однако, что если мы вернемся к этому перечислению в будущем и по ошибке доба- вим девятое индексное значение? Если в какой-то другой строке кода мы не проверим диа- пазон индексов, прибегнув к помощи утверждений, наш код будет пытаться установить и прочитать девятый бит в 8-разрядном значении, порождая ошибку. Даже если во время работы мы выполним проверку, чтобы отследить этот случай, мы не найдем его до тех пор, пока не перейдем в тот раздел кода, который использует этот enum. Короче говоря, прежде чем мы действительно отыщем эту простую ошибку, пройдет какое-то время. Простой способ защиты от подобных вещей - добавить в код утверждение времени компиляции. В данном конкретном случае мы знаем, что значение k_totalBitFlags должно быть равным или меньше 8 для объекта, имеющего дело с битовыми флагами в пределах 8 разрядов. Это значение нетрудно проверить, но как это сделать во время ком- пиляции, а не в момент исполнения?
310 ПРИЛОЖЕНИЕА Ответ - создать некорректный код. Тогда компилятор не сможет собрать приложение, Пока мы не исправим проблему. Чтобы создать такой злонамеренный код, применим еще один макрос, принимающий на вход наше условное выражение и преобразующий его в неверный код, если условие ложно. Для этого воспользуемся в макросе оператором выбора. Он содержит ветвь для значения 0 и ветвь для значения, основанного на нашем условии. #define compiler_.assert (х) {\ const int _jvalue = (х) ? 1:0;\ switch (х)\ {\ case 0: \ case _value: \ default: break;\ };} Если условие (х) истинно, _value принимает значение 1, давая нам две совершенно обычные ветви в операторе выбора. Однако, если условие ложно, _value становится равным 0, и в операторе выбора мы имеем пару избыточных операторов case. Это и поро- дит неверный код, который компилятор отловит и выдаст нам как ошибку. Этот метод имеет пару ограничений, причем первое из них очевидно, - проверяемое условие должно быть чем-то, что можно определить при компиляции кода. Сюда отно- сятся значения констант и размеров, определимых оператором sizeof (). Второе огра- ничение состоит в том, что макрос compiler_assert должен использоваться в пределах подлежащих компиляции функций. Помните, что шаблонные функции не компилируются, если реально не используются в других частях кода. Поэтому макроопределения compiler_assert, размещенные в этих местах, не компилируются и не проверяются. Текстовые сообщения при отладке Нередко возникает потребность отслеживать движение приложения по разным функ- циям и библиотекам. Быть может, нужно отследить значение некой переменной, состояние того или иного объекта или количество раз, когда выполняется заданное условие. Осущест- вление такого контроля с помощью макроопределений assert создает в приложении полную неразбериху из окон сообщений и критериев останова. Вместо этого мы создадим надеж- ный и качественный менеджер отладочных сообщений, который позволит нам выводить предназначенные для пользователей строки текста с целью мониторинга приложения. OutputDebugString решает эту задачу легко и естественно, выгружая любую пере- даваемую ему строку в стандартное окно вывода. Однако этот подход может быть весьма ограничен, особенно если мы хотим вывести значение той или иной переменной, для чего нам нужно сформировать собственную строку. Если же мы захотим записать строковую информацию в файл, а не выдавать ее в окно вывода, нам нужно создать для этого особое
СЛУЖЕБНЫЕ КЛАССЫ Gaia 311 решение, после чего в точке вывода каждого сообщения выбирать - должна эта информа- ция быть передана в OutputDebugString, отправлена в контроллер лог-файла или уйти по обоим адресам назначения. Взамен мы создадим единственный обработчик отладочных сообщений, который позволит нам на лету формировать выходную строку и помечать ее так, чтобы обработчик был в состоянии определить, куда ее передать. Для этого опишем сообщения 32 типов, обозначаемых битовым полем, указанным в каждом из отладочных сообщений. Далее мы можем подключить слушатель (listener), который назовем каналом отладки и который будет отслеживать тип сообщений для перенаправления их обработчику. В дальнейшем каналы смогут маршрутизировать сообщения в окно вывода, файл или куда-либо еще по нашему выбору. Это дает нам немалую гибкость в создании специальных журналов, содержащих данные о производительности движка, ввод от пользователя или сообщения об ошибках. Кроме того, это дает нам интерфейс для подключения модулей (plug-in), который мы можем развить с учетом будущих нужд, связанных с отладкой (например, удаленного мониторинга по сети). Для начала нам нужно определиться в том, что же такое канал отладки и как он рабо- тает. Наша реализация - это чуть больше, чем виртуальный интерфейс обработки основ выдачи сообщений. Коль скоро отдельные каналы могут быть связаны с файлами или иными внешними ресурсами, мы создадим общий интерфейс, допускающий открытие канала, запись в него и закрытие. Предположив, что все каналы открываются один раз при запуске приложения, запись в них осуществляется в течение времени его жизни, а закры- ваются они в конце его же работы, различий между перезаписью и дописыванием в конец канала не существует. Кроме того, каждый канал отладки нуждается в указании набора битовых флагов для тех типов сообщений, который он отслеживает. Описание базового класса, а также конкретный пример порожденного от него канала сообщений, отправ- ляющего все сообщения в стандартное окно вывода, приведены в листинге А. 10. В заго- ловочном файле движка debug_channel. h есть специальный канальный объект с именем cFileOutputChannel, который может служить для журнализации сообщений на диске. ЛИСТИНГ А. 10. Базовый класс канала отладки и порожденный от него пример класса для вывода сообщений в стандартный выходной буфер class cDebugMessageChannel { public: // общедоступное множество битовых флагов, // используемых для фильтрации сообщений u32Flags messageFilter; cDebugMessageChannel() messageFilter(0) {}
312 ПРИЛОЖЕНИЕ A virtual -cDebUgMessageChannel(){} private: // эти функции вызываются лишь // объектом cDebugMessageHandler friend cDebugMessageHandler; virtual bool open(){return true;} virtual void close(){} virtual bool write(const tchar* pText){} }; class cSystemDebugChannel : public cDebugMessageChannel { public: cSystemDebugChannel() { // принимаем сообщения любых типов messageFilter = Oxffffffff; } -cSystemDebugChannel(){} private: // выводим весь текст в стандартный выходной буфер bool write(const tchar* pText) { _tprintf(pText); _tprintf(_text("\n") ) ; return true; } } ; Теперь, когда основы описания уже позади, мы можем построить обработчик сообще- ний, который принимает всю входную информацию и маршрутизирует ее соответственно. Это простой класс, который получает строковый вход, помеченный набором флагов, обозначающих тип сообщения, после чего отыскивает в списке известных каналов-слуша- телей все те, кому это сообщение требуется отправить. В нашем описании - 32 флага по числу типов сообщений и до 32 пользовательских каналов для маршрутизации таковых. Мы ограничиваем себя тридцатью двумя, для того чтобы иметь возможность хранить оба информационных набора - фильтр сообщений и активные каналы - в формате 32-разряд- ных значений. Интерфейс класса дает пользователям возможность добавлять и удалять активные каналы отладки, активизировать их и снимать активацию, производить вывод в активные каналы текстовых сообщений. По умолчанию мы строим класс при помощи встроенного объекта cSystemDebugChannel (см. листинг А.10), поэтому он сразу же готов к приме-
СЛУЖЕБНЫЕ КЛАССЫ Gaia 313 нению. Введение дополнительных каналов или изменение свойств системного канала по умолчанию остается на усмотрение программиста. Нетривиальная часть задачи - умение сформировать входную строку на лету, которое подразумевает работу со строкой аргументов переменной длины (...). Многоточия позво- ляют передать функции разное число аргументов, за счет чего свою гибкость приобретают такие функции создания строк, как printf. Поскольку нам нужна такая же гибкость, вос- пользуемся тем же методом введения необязательных аргументов. Реально наша функция работы с текстовым входом не делает почти ничего, кроме упаковки необязательных аргу- ментов в соответствующий объект-список аргументов-переменных (va_list) и пере- дачи его немного отличной реализации printf для формирования самой строки. Функции вывода текста показаны в листинге А. 11. Для выполнения этой работы мы создадим три функции: одна фактически делает работу (processMessage), другие представляют собой два варианта функции вывода. Они дают возможность посылать текс- товый вывод обработчику сообщений как с флагом, помечающим тип сообщения, так и без его указания. Когда флаг опущен, используется внутренний флаг сообщений по умолчанию. Это позволит нам, не требуя каждый раз флаг, создать простые макросы для вывода текстовых сообщений, такие, как макроопределение TRACE. ЛИСТИНГ А.11. Три функции-члена cDebugMessageHandler для получения и обработки текстовых сообщений // перенаправим входной текст, используя внутренний флаг сообщений по умолчанию void cDebugMessageHandler::output(const tchar* text, ...) { // построим список необязательных аргументов как va_list va_list arglist; va_start(arglist, text); // вызовем va_list-версию функции вывода processMessage(k_defaultMessageFlag, text, arglist); // завершим работу co списком необязательных аргументов va_end(arglist); // перенаправим входной текст, используя полученный флаг сообщений void CDebugMessageHandler::output(uint32 messageFlags, const tchar* text, ...) { // построим список необязательных аргументов как va_list va_list arglist; va_start(arglist, text); // вызовем va_list-Bepcnto функции вывода processMessage(messageFlags, text, arglist);
314 ПРИЛОЖЕНИЕ A // завершим работу со списком необязательных аргументов va_end(arglist); } // функция, осуществляющая реальную // маршрутизацию текстовых сообщений void cDebugMessageHandler::processMessage( uint32 messageFlags, const tchar* text, va_list arglist) { // имеются ли каналы, открытые в данный момент? if (m_openChannels) { // сформируем нашу выходную строку tchar buffer[nMaxOutputStringSize+1]; int Size = _vsntprintf(buffer, nMaxOutputStringSize, text, arglist); // если строка сформирована... if(Size > 0) { // пройдем в цикле по всем каналам for (int i=0; ichMaxChannels; ++i) { // если канал открыт и принимает // этот тип сообщений... if (m_openChannels.testBit(i) && m_channel[i]->messageFilter.testAny(messageFlags)) { // пошлем ему сообщение m_channel[i]->write(buffer); } } } } } Хронометраж кода Помимо обнаружения ошибок и выдачи текстовых сообщений для нас самих в целях мониторинга приложений нам требуется простое средство периодической проверки эффективности кода, который мы пишем. Специализированные инструменты мониторинга производительности, такие, как Intel VTune™ или Compuware® DevPartner Profiler™,
СЛУЖЕБНЫЕ КЛАССЫ Gaia 31 способны выдать развернутый отчет о производительности, однако нам все равно полезш те методы профилирования, которые функционируют независимо от этих внешних про дуктов. Кроме того, иногда возникает потребность в отчетах о производительност! в реальном времени, когда информация о продолжительности выполнения конкретно! функции важна лишь в отдельных, ключевых ситуациях. Для получения высокоуровневы. временных характеристик мы создадим набор функций, призванных контролироват скорость нашего приложения в тех его аспектах, где мы захотим сами. Как и в случа с функциями слежения за невыполнением утверждений и вывода текста, мы реализуем их через механизм макроопределений, чтобы результаты сборки итоговой вереи] не содержали никакого дополнительного контрольного кода. Первый компонент нашей профилирующей системы - простой объект-таймер. Дл. получения отсчетов времени от системы этот класс использует метод QueryPerfor- manceCounter. Данный метод, пожалуй, быстрее (и точнее) других методов замер, времени, таких, как timeGetTime и GetTickCount, но не так эффективен, как приме нение встроенных счетчиков мониторинга производительности ЦП. Однако, поскольк; такие счетчики производительности в процессорах зависят от конкретных производи телей, мы будем избегать их применения в базовом классе нашего таймера. Впрочем простой интерфейс таймера, который мы создадим, позволит в будущем перевести ег< на методы отсчета времени конкретным процессором при возникновении потребносп в большей точности измерений. Объект cTimer будет предоставлять простой интерфейс, который позволит запускать останавливать таймер, переводить его в режим паузы и выводить из него, считывал в любой момент значение времени. Время будет передаваться как вещественное значение в долях секунды. Так мы поступим по той причине, что объект этого класса послужи' и при профилировании нашего кода, и как таймер общего назначения в самом приложе нии. Наличие же универсальной основы представления значений времени делает приме нение объектов-таймеров более интуитивно понятным. z" с Один из конкретных типов объекта cTimer - таймер для приложения, которые мы запустим при его старте и который будет работать до тех пор, пока про грамма не будет закрыта. Тогда мы сможем в любой момент запросить этот тайме} и узнать время, потраченное на выполнение нашей программы. Для нас это сделает клас< cApplicationTimer. Как класс, основанный на cTimer, cApplicationTimer помим< немедленного запуска сессии контроля времени после создания и закрытия этой сессш при уничтожении не содержит никаких новых функций. Это дает нам возможность поро дить глобальный экземпляр объекта этого класса, который будет назван просто applica- tionTimer, а создан и уничтожен вместе с самим приложением. Тогда запрос текущей
316 ПРИЛОЖЕНИЕ A времени в любой точке кода сведется к обычному вызову applicationTimer. elapsed- Time. Описания классов cTimer и cApplicationTimer можно найти в файлах timer. h и application_timer. h на компакт-диске. Теперь, когда объекты-таймеры созданы, нужно описать то, как будут вводиться операции контроля времени в наш код для мониторинга его эффективности. Чтобы оце- нить работу фрагмента кода во времени, необходимо отследить данные об общем количе- стве времени, потраченном на выполнение кода, и число раз, которое код выполнялся. По этим данным можно рассчитать среднее время выполнения кода. Для получения более подробной информации мы отследим также минимум и максимум зафиксированной про- должительности выполнения по всем запускам кода. Это даст нам дополнительное пред- ставление о максимальной флуктуации эффективности кода, который анализируется. В листинге А. 12 показан класс cCodeTimer, используемый для получения указанной информации. Этот класс содержит также пару указателей на предыдущий и последующий объект cCodeTimer, что позволяет объединять их в связанный список. Подход на основе свя- занных списков, в отличие от применения таблицы объектов cCodeTimer известного раз- мера, более гибок и допускает наличие в движке переменного числа активных таймеров кода. ЛИСТИНГА. 12. Описание объекта cCodeTimer и его ключевые функции-члены class cCodeTimer { public: static cCodeTimer RootTimer; // Константы и типы данных... enum eConstants { k_maxNameLength = 32 }; // Общедоступные данные (public)... // Конструкторы... cCodeTimer(const tchar* name_string); -cCodeTimer(){}; // Операторы... // Методы модификации... void beginSession(); void endSession(); void reset(); void resetAllTimers(); void outputAllTimers(u32Flags MessageFlags);
СЛУЖЕБНЫЕ КЛАССЫ Gaia 317 // Методы доступа. . . float averageTime()const; float totaiTime()const; uint32 totalcalls()const; float maximumTimeSample()const; float minimumTimeSample()const; const tchar* name()const; private: // Собственные данные (private)... cCodeTimer* m_nextProfile; cCodeTimer* m_lastProfile; float m_totalTime; uint32 m_totalCalls; float m_maximumTimeSample; float m_minimumTimeSample; tchar m_name[kjnaxNameLength]; float m_startTime; static cCodeTimer* s_previousTimer; }; ' // конструктор. cCodeTimer::cCbdeTimer(const tchar* name_string) :m_nextProfile(0) ,m_lastProfile(s_previousTimer) ,m_totalTime(O.Of) ,m_totalCalls(0) , mjiaximumTimeSample (O.Of) ,m_minimumTimeSample(O.Of) ,m_startTime(O.Of) { debug_assert(name_string, "A name must be provided to the code timer"); // "Таймер кода должен иметь свое имя!" // зафиксируем имя таймера Istrcpyn(m_name,name_string, k_maxNameLength); // добавим этот таймер в конец цепочки из таймеров if (s_previousTimer) { s_previousTimer->m_nextProfile = this; } s_previousTimer = this;
318 ПРИЛОЖЕНИЕ A } // начнем хронометраж кодового фрагмента void cCodeTimer::beginSession() { ++m_totalCalls; if (!m_startTime) { m_startTime = applicationTimer.elapsedTime(); } } // закончим хронометраж кодового фрагмента void cCodeTimer::endSession() { if (m_startTime) { float endTime = applicationTimer.elapsedTime(); float sample = endTime - m_startTime; m_totalTime += sample; m_maximumTimeSample = maximum(m_maximumTimeSample, sample); m_minimumTimeSample = minimum(m_maximumTimeSample, sample); m_startTime = O.Of; } } Все объекты типа cCodeTimer хранятся как статические данные в том коде, который они профилируют. Размещение этих данных в самом коде нарушает чистоту взятого под контроль фрагмента, но при профилировании на высоком уровне, как сейчас, издержки должны быть минимальны. Статический член в самом объекте cCodeTimer содержит указатель на последний созданный объект cCodeTimer, поэтому новый объект того же типа способен присоединиться к нему и продолжить цепочку. При обнаружении подлежа- щего мониторингу кода статические объекты cCodeTimer формируются и расширяют цепь. Макроопределения, которые служат для создания таких статических объектов по именам, переданным на вход макроса для построения локальных экземпляров каждого cCodeTimer, приведены в листинге А.13. ЛИСТИНГА. 13. Макроопределения для включения объекта cCodeTimer в состав подлежащего мониторингу кода // начнем сессию контроля времени [name] с создания // локального статического члена с именем _ct_[name] // и открытия сессии с его применением
СЛУЖЕБНЫЕ КЛАССЫ Gaia 319 #define begin_profile(name) static \ cCodeTimer _ct_##name(_text(#name));\ _ct_##name.beginSession(); // завершим сессию контроля времени по ее имени ttdefine end profile(name) _ct_##name.endSession(); Произвести мониторинг фрагмента кода - значит просто разместить в его начале и конце начальный и концевой контрольные макросы и передать им надлежащее имя, которое описывает сам код. К примеру, чтобы профилировать время, затраченное внутри цикла, можно записать такой код: begin_profile(main_game_loop); while(f finished) { // сыграем... }; end_profile(main_game_loop); Пользуясь макроопределениями из листинга А. 13, компилятор развернет этот код, чтобы создать локальный статический объект cCodeTimer и профилировать искомый фрагмент кода: static cCodeTimer _ct_main_game_loop("main_game_loop"); _ct_main_game_loop.beginSession(); while(!finished) { // сыграем }; _ct_main_game_loop.endsession(); Чтобы периодически запрашивать значения, находящиеся в таймерах кода, можно вызывать статическую функцию outputAllTimers с набором флагов сообщений. На уровне реализации класс cCodeTimer просматривает все объекты в связанном списке и передает их данные как строку в объект cDebugMessageHandler. При помощи по- лученного флага сообщений выходной текст будет направлен в нужный канал отладки, допуская отображение данных в окне вывода, запись их в файл и т. д. по нашему усмотре- нию. Тем самым реализуется удобный способ получения «мгновенных снимков» произво- дительности приложения во время его работы. На пути к дальнейшему упрощению контроля времени выполнения можно использовать еще один автоматический класс, который профилирует любую заданную область исходного кода. Так же как объект cApplicationTimer допускает автоматическое применение объек- та cTimer на протяжении всего времени своей жизни, мы можем создать профилирующий объект, который бы занимался хронометражем кода во время существования того или иного объекта. Открыв сессию контроля времени при создании объекта и завершив ее при уничто-
320 ПРИЛОЖЕНИЕ A жении, мы можем профилировать время, затраченное при нахождении объекта в данной области, создав профиль самой этой области. Исходный код такого простого класса для про- филирования cScopeTimer, а также использующий его макрос prof ile_scope показаны в листинге А. 14. Как и другие макроопределения контроля времени кода, макрос prof ile_scope порож- дает статический объект cCodeTimer, основываясь на уникальном имени, которое передано ему на вход. Этот объект статичен, поэтому он создается и включается в связанный список cCodeTimer лишь один раз, при первом выполнении макроса. Кроме того, макрос prof ile_scope создает временный cCodeTimer и передает ему указатель на объект контроля времени кода. Код профилируется, пока активен объект cScopeTimer. Как только cScopeTimer выходит из области видимости и уничтожается, сессия хронометража автома- тически завершается. В итоге вставка единственного макроопределения profile_scope в начале функции позволит профилировать всю функцию целиком, начиная с точки располо- жения макроса и заканчивая выходом из самой функции. ЛИСТИНГ А. 14. Объект cScopeTimer и макроопределение для автоматизации его применения class cScopeTimer { public: cScopeTimer(cCodeTimer* timer) :m_internalTimerLink(timer) { debug_assert(m_internalTimerLink, "A timer link must be provided"); // "Требуется связь с таймером" m_internalTimerLink->beginSession(); } -cScopeTimer() { m_internalTimerLink->endsession(); } private: cCodeTimer* m_internalTimerLink; }; ttdefine profile_scope(name) static \ cCodeTimer _ct_##name(_text(#name));\ cScopeTimer _ft_name(&_ct_##name);
СЛУЖЕБНЫЕ КЛАССЫ Gaia 321 Литература [Rabin] Rabin, S. «Squeezing More Out of Assert». Game Programming Gems. Charles River Media, Inc., 2000, p. 109-114. [Robbins] Robbins, J. «Bugslayer». Microsoft Systems Journal. February 1999 (работа дос- тупна по адресу www.microsoft.com/msj/defaultframe.asp7page-/msj/0299/bugslayer/ bugslayer0299. htm). [Wyatt] Wyatt, R. «Processor Detection and a Pentium III Update» (работа доступна по адресу www.gamasutra.com/features/wyatts_world/l9990709/processor detection_01 .htm). It - 1S39
ПРИЛОЖЕНИЕ В СЕКРЕТЫ ПЛАВАЮЩЕЙ ЗАПЯТОЙ Располагать базовым комплектом математических средств для работы с целыми числами - это уже неплохо, однако инструментарий Gaia должна отличать максимальная эффективность. Весь смысл того, что мы тратим немало времени на построение инстру- ментария для разных манипуляций над битами, состоит в том, чтобы упростить нашу жизнь в будущем, поэтому мы и хотим получить качественный интерфейс для работы как с целыми, так и с вещественными значениями. Простой способ добиться этого - заставить наши математические инструменты, созданные для вещественных чисел, обращаться за поддержкой к стандартной библиотеке Math языка С. Во многих случаях мы именно так и поступим, однако в отдельных немногочисленных ситуациях мы обойдем стандартную библиотеку С и создадим свои собственные варианты реализации. Работа с вещественными числами на компьютере, созданном для работы в двоичном режиме, ставит две проблемы, которые требуется решать: скорость и точность при вычис- лениях. Скорость вызывает обеспокоенность потому, что хотя в стандартной библиотеке Math языка С и содержатся все арифметические операции с плавающей запятой, которые нам могли бы потребоваться, многие из них выполняются слишком медленно для частого применения. Единственное, что отделяет нас от эпохи математических библиотек с фик- сированной запятой, - это специальное устройство для выполнения операций с пла- вающей запятой в составе процессора (FPU, Floating-point Processing Unit). Точность обретает черты проблемы прежде всего оттого, что сам компьютер, если подумать, не может реально представлять вещественные значения. То, что мы имеем на деле, - это аппроксимации, которые отклоняются от истинного значения на величину некой малой ошибки. По мере участия таких приближенных значений в математических действиях ошибки объединяются, и результат может быть немного отличен от ожидае- мого. Что же в итоге? Уравнения, где вы надеетесь получить ноль, иногда выдают такие «микроскопические» значения, как 1,0е—13; векторы, которые вы нормируете, в резуль-
СЕКРЕТЫ ПЛАВАЮЩЕЙ ЗАПЯТОЙ 323 тате имеют длину, чуть большую или чуть меньшую единицы; ограничительные прямо- угольники, которые, как вы думаете, соприкасаются сторонами, перекрывают друг друга на невероятно малую величину. Достаточно, чтобы почувствовать всю прелесть вещест- венной математики. В математическую часть библиотеки движка, чтобы решить эти проблемы, мы добавим несколько полезных макроопределений и утилит. Но для этого необходимо общее понима- ние формата вещественных чисел. Этот формат описан Институтом инженеров по электротехнике и электронике (IEEE, Institute of Electrical and Electronics Engineers, Inc.) и принят большинством, если не всеми производителями процессоров для ЭВМ. Я не боль- шой любитель читать спецификации IEEE и сомневаюсь, что вы тоже из их числа. К счастью для нас, программисты уже прошли этот путь и создали немало документации, посвящен- ной стандарту IEEE и трюкам в обращении с ним. Организация вещественных данных Вещественные данные располагаются в машине согласно особому битовому шаблону, созданному для их хранения. Глядя на биты шаблона, мы можем разбить вещественное число на части и собрать о нем некую существенную информацию. Формат удовле- творяющего стандарту IEEE 32-разрядного числа с плавающей запятой - это один знако- вый бит, за которым следуют 8 бит порядка и 23 бита мантиссы. Битовый шаблон вещест- венного числа обычной точности показан на рис. В.1. +/- порядок мантисса О 00000000 1 00000000000000000000000 31 30 23 22 0 Рис. В. 1. Битовый шаблон 32-разрядного числа с плавающей запятой по стандарту IEEE. Старший разряд - знак, за ним следуют 8 разрядов порядка и 23 разряда мантиссы Немногие углеродные формы жизни используют, говоря о значениях чисел, порядки и мантиссы, так что подобный формат может поначалу показаться немного «чужим». Далее мы приведем пример, после чего изучим несколько методов работы с веществен- ным форматом, не интересуясь самими мантиссами и порядками. Пример будет состоять в переводе десятичного значения 8,75 в набор бит в формате с плавающей запятой. Чтобы привести десятичное значение к битовому шаблону вещественных чисел, сначала его надлежит выразить в двоичной форме. Перевести значение 8,75 непросто, так как оно содержит дробную часть. Записать целую часть, то есть 8, достаточно легко - в двоичной форме она примет значение 1000. Чтобы преобразовать дробную часть (0,75), вспомним, что
324 ПРИЛОЖЕНИЕ В биты справа от десятичной запятой представляют значения 2-1, 2-2, 2-3 и т. д. Также эти позиции можно трактовать как V2, '/д, % и т. д. Стало быть, 0,75, а это то же самое, что J/2 (первый двоичный разряд) плюс ]/4 (второй двоичный разряд), в двоичном виде будет записа- но как 0,11. Склеим оба значения и получим запись 8,75 в двоичной форме вида 1000,11. Приведение к двоичному виду - не очень большая проблема, однако формат с пла- вающей запятой создан для хранения чисел в научной нотации. Чтобы быть точным, он призван отражать нормализованное значение в научном формате. Звучит труднопроиз- носимо, но все же построить такое значение достаточно просто. Все, что нужно, - норма- лизовать двоичное число. Это еще один, хотя и весьма своеобразный, способ сказать: «Переместите двоичную запятую до положения справа от самого старшего разряда и запи- шите перемещение двоичной запятой как порядок». Это сведет значение 1000,11 к эквива- лентному ему, нормализованному варианту в научной нотации: 1,00011 х 2Л3. Таково исходное значение, нормализованное путем сдвига двоичной запятой на три места влево и указания 3-разрядного перемещения как порядка. При разбиении на составляющие 1,00011 х 2Л3 можно прочитать как мантиссу 1,00011, порядок 3 и знаковый разряд 0 (значение положительно). Все три значения необ- ходимы для заполнения структуры данных с плавающей запятой, однако до построения итогового битового шаблона нас ждет еще пара серьезных испытаний. Во-первых, порядок должен быть выражен в смещенной форме. Он может быть поло- жительным и отрицательным, однако для его представления в формате данных с пла- вающей запятой имеется всего восемь разрядов. Формат IEEE решает эту проблему деле- нием диапазона значений порядка на две половины, положительную и отрицательную. Заметим, что 8 бит могут содержать 256 различных значений (от 0 до 255). Поделив этот диапазон надвое, получим, что одна половина может служить для записи отрицательных чисел, другая - для записи положительных. Величина смещения составит 127, поскольку именно эта средняя точка и представляет ноль. Отрицательные значения попадают в диа- пазон от 0 до 126, положительные - в диапазон от 128 до 255. Чтобы занести порядок в шаблон, просто прибавим к нему смещение для его сдвига в нужную половину. Значение порядка, равное 3 (из предыдущего примера), будет сохранено как 127 + 3, или 130, — через три единицы от начала положительного полудиапазона. Далее следует самоочевидная задача - сформировать значение, которое мы сохраним как мантиссу. Все мантиссы с плавающей запятой хранятся в нормализованном двоичном виде. Напомним, что для нормализации двоичного числа вы просто сдвигаете двоичную запятую вправо до самого старшего ненулевого разряда. Это значит, что все нормализованные дво- ичные числа имеют вид 1 где х - любое число замыкающих бит. Поскольку наличие слева от двоичной запятой только одного разряда гарантировано, то нет необходимости его и хра- нить. Вещественный формат данных предполагает хранение лишь тех разрядов мантиссы, которые находятся от двоичной запятой справа. Единственный разряд слева от запятой отбра-
СЕКРЕТЫ ПЛАВАЮЩЕЙ ЗАПЯТОЙ __325 сывается, однако по-прежнему используется при всех расчетах с вещественными числами как старший подразумеваемый бит мантиссы. Если мантисса из предыдущего примера равнялась 1,00011, то реально будут сохранены разряды ООО 11, за которыми последуют 18 нулевых бит, необходимых для заполнения 23-разрядного пространства мантиссы. Теперь все три части вещественного формата данных готовы. Пример исходного значе- ния 8,75 переведен в нормализованный научный формат: 1,00011 х 2Л3. Значение порядка, равное 3 и полученное при нормализации, было смещено для размещения в 8-разрядном пространстве в виде числа 130, а подразумеваемый старший бит удален из мантиссы. Теперь, установив бит знака (нуль для положительного исходного значения 8,75), сдвинув в нужное положение 8-битный порядок и задав биты мантиссы, мы можем построить ито- говый шаблон бит в формате с плавающей запятой. Результат показан на рис. В.2. +/- порядок мантисса 0 10000010 00011000000000000000000 31 30 23 22 ' О 1 Рис. В.2. Значение 8,75, переведенное в формат с плавающей запятой. 8,75 в двоичном виде есть 1000,11, или после нормализации 1,00011 х 2Л3. Это соответствует тому, что значение бита знака равно 0, порядок 3 и мантисса 1,00011. Порядок смещается и становится равным 130 (в двоичной записи 10000010); старший бит мантиссы не сохраняется, в результате в шаблон будут записаны лишь биты 00011, дополненные до 23-разрядного пространства мантиссы Итак, как проще представить все эти данные о значении мантисс и порядков? Ключом к ответу станет мысленное представление хранящихся в памяти компонентов в виде струк- туры, построенной на сдвиге разрядов, а не на научной нотации. 8-разрядное значение порядка - это число 130, преобразуемое в исходное значение 3, если вычесть смещение. Это и есть то количество двоичных позиций, на которое для получения исходного значения 23-битная мантисса должна быть сдвинута влево. Разряды мантиссы содержат значение 00011 (плюс 18 бит замыкающих нулей), которое превратится в 1,00011, когда мы вернем на место подразумеваемый старший бит и двоичную запятую. Если двоичную запятую сместить вправо на три разряда, получится исходное значение 1000,11, или 8,75. Поэтому числа в формате с плавающей запятой можно предста- вить себе как бит знака, величину сдвига и сдвигаемое значение, а не - говоря научным языком - знак, порядок и мантиссу числа. Вооружившись этими знаниями, мы можем рассмотреть ряд приемов, которые исполь- зуют формат IEEE. Для выполнения задач кодирования и декодирования разрядов можно описать несколько удобных макроопределений. Они станут своего рода «строительными блоками», лежащими в основе тех трюков, которые мы будем совершать дальше.
326 ПРИЛОЖЕНИЕ В // интерпретируем float как int32 #define fpBits(f) (*reinterpret_cast<const int32*>(&(f))) // интерпретируем int32 как float #define intBits(i) (*reinterpret_cast<const float*>(&(i))) // вернем 0 или -1 в зависимости от знака float-параметра #define fpSign(i) (fpBits(f)>>31) // выделим 8 разрядов порядка как целое со знаком, // для чего наложим маску на эти разряды, сдвинем их // на 23 бита и вычтем величину смещения, равную 127 #define fpExponent(f) (((fpBits(f)&0x7fffffff)>>23)-127) // вернем 0 или -1 в зависимости от знака порядка #define fpExponentSign(f) (fpExponent(f)>31) // получим 23 бита мантиссы с восстановленным подразумеваемым битом #define fpMantissa(f) ((fpBits(f)&0х7fffff)| (1<<23)) Знаковый разряд Первое, на что следует обратить внимание, - это тот факт, что бит знака в составе чисел с плавающей запятой находится на месте старшего значащего разряда, то есть на том самом месте, где находится знак у целых длиной 32 бита. Стало быть, знак вещест- венных чисел можно определить, трактуя их как целые числа. Для этого значение с пла- вающей запятой можно условно принять за целое, а затем выяснить его знак. Зная об этом, мы можем теперь обновить созданную в последней главе функцию- шаблон samesigns (). Чтобы два числа с плавающей запятой имели одинаковый знак, они должны иметь одинаковые знаковые разряды. Сравнение их знаков становится делом срав- нения разрядов знака того и другого числа. Для этого функция при помощи сдвига преобра- зует каждый знаковый бит в маску, после чего просто сравнивает маски между собой. Макрос fpSign() служит для выполнения за нас операции reinterpret_cast и сдвига. templateo , inline bool SameSigns(float ValueA, float ValueB) { return (fpSign(ValueA) == fpSign(ValueB)); } Если использовать знаковые разряды, то столь же тривиален и перевод чисел с пла- вающей запятой в их абсолютные величины. До сих пор функция-шаблон abs() использова- ла функцию fabs() стандартной библиотеки языка С, но так же просто проделать эту работу и нам самим. Для перевода любого вещественного числа в положительное значение требу- ется очистить знаковый бит в 31 -й позиции. Преобразовать любое число с плавающей запя- той к абсолютной величине можно, выполнив побитовую операцию AND с маской, содержащей 30 младших разрядов.
СЕКРЕТЫ ПЛАВАЮЩЕЙ ЗАПЯТОЙ 327 template о inline float abs(float value) { uint32 absValue = fpBits(value); absValue &= 7fffffff; return intBits(absValue); Преобразование вещественных чисел в целые Имеющийся в стандартной библиотеке С метод преобразования чисел с плавающей запятой в целые невыносимо медлителен и требует- в зависимости от платформы и компилятора - порядка 60 процессорных циклов. Другой проблемой, нуждающейся в решении, является то, что стандарт ANSI С требует усечения дробных значений при переводе вещественных чисел в целые, а это действие желательно не всегда. Вкупе эти обстоятельства делают принятое в стандартной библиотеке С соглашение о переводе чисел в целые неприемлемым для движка Gaia. В математической библиотеке во избежание запуска таких затратных вещественных процедур, как расчет синуса и косинуса, мы будем использовать предварительно рассчи- танные (1оокир-)таблицы. Чтобы упростить их применение, потребуется быстрый и надежный способ перевода исходных вещественных значений в целочисленные таб- личные индексы. Кроме того, в процессе преобразования дробных частей в целые нам могут понадобиться различные методы округления. Все эти задачи можно решить путем хитроумных преобразований 32-разрядных значений с плавающей запятой. Первый этап состоит в эмуляции простой ANSI-операции приведения, усекающей все дробные части исходного вещественного числа до получения целочисленного результата. Ее шаги изложены в функции realToInt32(), представленной в листинге В.1. Не обращайте пока внимания на макрос flipSign(), его роль будет описана при изучении процедуры кон- версии. ЛИСТИНГ В.1. Преобразование 32-разрядных вещественных чисел в целые // flipSign - вспомогательный макрос // для инверсии знака i при flip, равном -1; // если flip равно 0, макрос не делает ничего #define flipsign(i, flip) ((1Л flip) - flip) inline int32 realTo!nt32 (const float& f) { // произведем чтение порядка и выясним, // насколько нам надлежит сдвинуть мантиссу int32 shift = 23-fpExponent(f);
ПРИЛОЖЕНИЕ В // произведем чтение мантиссы и сдвинем ее // для удаления всех дробных значений int32 result = fpMantissa(f)»shift; // установим знак вновь полученного результата result = flipSign(result, fpSign(f)); // если порядок был отрицательным, то есть // (-1.0 < f < 1.0), мы должны вернуть ноль result &= -fpExponentSign ( f) ; // вернем результат return result; } Пройдемся по этому преобразованию, чтобы понять, как оно производится. Первый шаг - определить, насколько нам предстоит сдвинуть мантиссу. Обычно позицию, куда в пределах мантиссы нужно поместить двоичную запятую для восстановления вещест- венного значения, описывает значение порядка. Поскольку сейчас двоичная запятая нахо- дится над 23-м разрядом, то сдвиг значения мантиссы на (23 - порядок) шага вправо оста- вит лишь биты, расположенные от двоичной запятой слева. Второй шаг считывает значе- ние мантиссы (с восстановлением подразумеваемой единицы в 24-м разряде) из вещест- венного числа и выполняет сдвиг, порождая целочисленный вариант исходного значения с плавающей запятой, в котором полностью удалена дробная часть. Если бы мы знали, что вещественное значение на входе было больше нуля и не было исключительно дробным (то есть лежало вне диапазона [-1,0, 1,0]), то функция могла бы на этом и завершиться. Однако для работы с произвольными входными значениями с пла- вающей запятой ей требуется еще несколько дополнительных шагов по обработке отрица- тельных чисел и чисел из диапазона [-1,0, 1,0]. Для обработки отрицательных чисел служит макрос flipSign(). Он пользуется хитрым приемом «переворачивания» знака целого числа при помощи входной маски. Эта маска должна равняться 0 или -1, что соответствует тому, что все биты в ней обнуле- ны или установлены в 1. Если все биты очищены, макрос ничего не дает. Если же каждый разряд маски переворота выставлен в 1, то для инверсии знака входного значения i, делающей положительное число отрицательным и наоборот, маска использует комбина- цию вычитания с поразрядной операцией XOR. Ввиду своего побитового характера эта операция, производимая вкупе с вычитанием, может закончиться гораздо быстрее, чем умножение на входное значение -1, выполняемое для инверсии знака. Функция realToint32 может использовать сильные стороны этого макроса, так как по алгоритму мы на данный момент получили положительное целое, даже если исходное значение с плавающей запятой является отрицательным. Если исходное число отрица- тельно, то знак целого результата должен быть инвертирован. Вспомните, что знаковый бит вещественного значения хранится в 31-м разряде. Если битовый шаблон вещественного числа сдвинуть на 31 позицию вправо, он превратится в маску из всех нулей для положи-
СЕКРЕТЫ ПЛАВАЮЩЕЙ ЗАПЯТОЙ 329 тельных чисел и из всех единиц - для отрицательных. По случайному совпадению именно этого требует макрос flipsign (). Если исходное значение на входе положительно, будет построена пустая маска, и макрос не сделает ничего. Если исходное значение на входе отрицательно, будет построена маска, заполненная от начала и до конца, и макрос проин- вертирует знак целочисленного ответа. Последний шаг - обработка целиком дробных значений, лежащих между -1,0 и 1,0. Если входное значение находится в этом диапазоне, то целочисленный результат, найден- ный к этому времени, является некорректным. Верное возвращаемое значение - ноль, поскольку все дробные числа с плавающей запятой между-1,0 и 1,0 при усечении прирав- ниваются к целому нулевому значению. Для проверки этого случая используется тот же трюк с превращением битового разряда в маску, что и при инверсии знака, но на сей раз для построения маски служит знак порядка числа. Если вещественное число лежит между -1,0 и 1,0, его порядок является отрицатель- ным. Если маска составлена из знакового разряда порядка, она будет пустой для положи- тельных порядков и всюду заполненной - для отрицательных. Если эту маску проин- вертировать, то при необходимости ею можно воспользоваться при поразрядном выполне- нии операции AND, чтобы свести целочисленный результат к нулю. В итоге мы имеем быстрый метод преобразования значений с плавающей запятой в эквивалентные целые. По сравнению со стандартным приведением типов в языке С новый метод работает примерно в пять-шесть раз быстрее. К тому же он способен дать дополнительный выигрыш, поскольку совсем не пользуется процессорным блоком для обработки вещественных чисел (FPU). Это делает FPU доступным для параллельного выполнения иных операций над числами с плавающей запятой. Для реализации других методов округления в заголовочный файл numeric_tools .h также включены методы преобразования вещественных чисел в целые с применением методов округления вниз, вверх и до ближайшего целого. Эти дополнительные функции созданы на основе описанных здесь базовых методов и по-своему определяют, какие биты мантиссы останутся справа от двоичной запятой. Это дробное значение служит для того, чтобы решить, должен ли целочисленный результат быть увеличен или уменьшен на еди- ницу в зависимости от применяемого метода округления. Ограничение точности вещественных чисел Вещественные числа могут иметь невероятную точность, и это может быть как их плюсом, так и их минусом. При работе с очень небольшими значениями точные вещест- венные числа прекрасно подходят для дела. Однако когда микроскопическая точность не требуется, это может вызвать проблемы, когда вы ждете их меньше всего. Подчас мелкие неточности в математике вещественных чисел могут привести к непредсказуемым
330 ПРИЛОЖЕНИЕ В результатам. Чтобы помочь в решении этой проблемы, добавим в заголовочный файл numeric_tools . h еще одну функцию. Теперь, когда мы знаем, как читаетсядформат чисел с плавающей запятой по стандарту IEEE, мы можем использовать свои знания для управления точностью. В тех случаях, когда ее требуется урезать, дробную часть для ограничения точности можно извлечь из мантиссы и заменить меньшим количеством бит. Это даст функциям возможность управлять тем, каким количеством бит точности (до 23) может сопровождаться двоичная запятая. В дейст- вии этот метод показан в листинге В.2, где он принимает на вход значение с плавающей запятой и точность в разрядах и возвращает округленное вещественное значение. Листинг В.2. Сокращение точности вещественных чисел float trimFloat(float input, int32 precision) { float result = input; int32 exponent = fpExponent(input); int32 bias = 23 - (exponent + precision); if (bias < 1) { return result; } if (bias > 24) { result = O.Of; return result; } int32 value = fpBits(input); if (bias == 24) { value &= (1<<31); exponent = -precision; value += (exponent + 127)<<23 ; memcpy(&result, lvalue, sizeof(value)); return result; } _asm { clc mov ecx, bias mov eax, value shr eax, cl
СЕКРЕТЫ ПЛАВАЮЩЕЙ ЗАПЯТОЙ 331 adc еах, О shl еах, cl mov value, еах }; memcpy(&result, &value, sizeof(value)); return result; }; В методе trimFloat для выполнения нашей задачи используется несколько строк на языке встроенного ассемблера. Как всегда, время от времени дела обстоят так, что сде- лать что-либо на ассемблере можно гораздо быстрее, чем на языке C++. В ассемблерном коде мантисса сдвигается вправо для сброса с регистра всех ненужных разрядов. Однако последний бит, который предстоит сбросить, должен пройти проверку. Вспомните: каждый двоичный разряд представляет половину соседнего с ним старшего бита. Это значит, что если последний бит, который мы хотим сбросить, установлен, то все отсекае- мое значение в целом больше половины значения последнего сохраняемого разряда, и мы должны округлить вверх. К счастью, последний сдвинутый нами бит остался на процес- соре как флаг переноса. Очередная инструкция ADC (add-with-carry, сложение с перено- сом) прибавит содержимое флага переноса к нашему оставшемуся значению. Если последний бит, сброшенный с регистра, был установлен, то установлен будет и флаг пере- носа, и операция ADC инкрементирует оставшееся на регистре значение. Если последний отсекаемый бит сброшен, то сброшен будет и флаг переноса, и значение на регистре оста- нется неизменным. Если значение на регистре сдвинуть затем -в его прежнее положение, то результатом этого станет мантисса, округленная до желаемой точности. Эта функция может быть очень удобной и заслуживает того, чтобы иметь ее в своем багаже. Числа можно округлять до желаемой точности, чтобы контролировать дрейф пла- вающей запятой. Кроме того, вещественные значения теперь можно легко округлять до определенного прироста их точности. Примеры тому можно найти в захвате углов вра- щения или настройке временных значений до фиксированных интервалов. Ограничение диапазона значений вещественных чисел Округлять числа с плавающей запятой для управления дрейфом, вызванным накопле- нием ошибки, полезно, но иногда нужно попросту гарантировать, что число ограничено известным диапазоном. Очень часто числовые значения нужно ограничить диапазоном [О, 1] илй поместить в диапазон отрицательных или положительных чисел. В заголо- вочном файле FloatTools.h нами описано несколько дополнительных функций, облегчающих операции ограничения подобного рода.
332 ПРИЛОЖЕНИЕ В Проще всего ограничить значение диапазоном положительных чисел (если f < 0, уста- новить f = 0). При условии, что бит знака расположен в 31-м разряде, мы можем просто пере- нести этот бит в маску. Наложив эту битовую маску на исходное число с помощью поразряд- ного оператора AND, мы обнулим все отрицательные значения. Ограничение диапазоном отрицательных чисел (если f > 0, установить f = 0) действует во многом аналогично: доста- точно проинвертировать битовую маску для обнуления всех положительных чисел. float clampPositive(float input) { // если значение отрицательно, примем его равным нулю int value = fpBits(input); int sign_mask = -fpSign(input); return intBits(value & sign_mask); } float clampNegative(float input) { // если значение положительно, примем его равным нулю int value = fpBits(input); int sign_mask = fpSign(input); return intBits(value & sign_mask); } Используя эти две функции, мы можем ограничить себя диапазоном выше или ниже любого числа с плавающей запятой. Если желаемое ограничение предварительно вычесть из исходного значения, то можно произвести положительное или отрицательное огра- ничение. Далее к результату можно снова прибавить значение вычитаемого, что даст значение с ограничением выше либо ниже желаемой точки. К примеру, чтобы ограничить значение диапазоном не более двух (если f > 2,0, установить f = 2,0), можно воспользо- ваться функцией, подобной следующей: float clampBelowTwo(float input) { float result = input - 2. Of; clampBelowZero(input); return (result + 2.Of); } Вслед за нулевым, значением, которым чаще других ограничивают числовые диапа- зоны, является 1,0. При этом можно использовать метод, аналогичный тому, который при- водится в примере с ограничением не более 2,0, но есть способ быстрее. В примере сме- щение входного значения и дальнейшее восстановление смещения после ограничения дважды нагружает FPU, и это решение не идеально. Коль скоро ограничение с участием 1,0 осуществляется куда чаще, имеет смысл создать более эффективный прием. Благодаря тому что порядок находится в старшем, в сравнении с мантиссой, диапазоне бит, мы знаем, что если одно положительное вещественное число больше другого, то его
СЕКРЕТЫ ПЛАВАЮЩЕЙ ЗАПЯТОЙ битовый шаблон по правилам IEEE также окажется больше шаблона другого при их интерпретации целыми. Значение 1,0 с плавающей запятой имеет особый битовый шаблон из всех нулей, за исключением 7 младших разрядов порядка (127 « 23). Это делает про- верку очень простой. Если любое положительное вещественное число имеет эквивалент- ный ему битовый шаблон, который превышает (127 « 23), то это значение с плавающей запятой также больше, чем 1,0. Заметим, что хотя этот трюк будет работать при сравнении двух отрицательных чисел, он не пройдет со смесью из отрицательных и положительных значений. Бит знака нахо- дится в позиции старшего разряда, поэтому отрицательные числа с плавающей запятой всегда будут больше своих положительных «двойников» при сравнении их как беззнако- вых целых. Это приведет к неудаче на наших тестах, если не принять мер, коррек- тирующих знаковый бит. float clampBelowOne(float input) { // если значение больше, чем единица, примем его равным 1 uint32 value = fpBits(input); uint32 mask = (-fpSign(input)) & 0x7fffffff; uint32 new_val = value & mask; new_val -= (127<<23); new_val >= 31; uint32 one = (127<<23) & -new_val; value = (value & new_val) + one; return intBits(value); } Единственный случай, когда учет знака не вызывает проблем, — потребность уложиться в диапазон [-1, 1]. Такая операция может оказаться полезной при ограничении нормирован- ных векторов, которое бы гарантировало, что никакой дрейф вещественной точности не вы- водит их за пределы области единичной длины. Поскольку единственной заботой для нас является то, не превышает ли число с плавающей запятой по абсолютной величине 1,0, мы можем проигнорировать знаковый бит и одновременно ограничить как положитель- ный, так и отрицательный диапазоны, float clampUnitSize(float.input) { // если абсолютная величина больше, чем единица, // примем ее равной 1 uint32 value = fpBits(input); uint32 abs_value = value & 0x7fffffff; abs_value -= (127<<23); abs_value >= 31;
334 ПРИЛОЖЕНИЕ В uint32 one = (127<<23) & ~abs_value; value = (value & abs_value) + one; return intBits(value); Вещественные степени числа 2 ' Последний вопрос из области вещественной математики, который мы здесь Ч'~-—рассмотрим, касается округлений до значений степени числа 2. Этот тип операций над числами с плавающей запятой используется не так часто, однако иметь быстрый способ выполнения подобных расчетов в нашем наборе не повредит. Как и в случае с переводом вещественных чисел в целые, наша библиотека будет содержать варианты реализации этой операции для округления до ближайшей, а также для отыска- ния значений соседней большей или соседней меньшей степени числа 2. Эти функции можно найти в файле source\core\numeric_tools .h на компакт-диске. Как обычно, усечение - простейшая вещь, таким же будет пример, который мы ниже рас-' смотрим. Перевести вещественное число в значение степени 2 несложно: достаточно отбро- сить биты мантиссы. Вспомним, что значение порядка берется из научной формулы (мантисса х 2поряд), поэтому если мантисса равна нулю, то все, что остается, - это усечен- ное значение степени 2. Заметим, что это усечение действует и для отрицательных чисел, воз- вращая отрицательный результат. Входное значение 2,35 вернет -2,0, и -2,35 на входе тоже даст -2,0. Строго говоря, это не настоящие степени двух, поскольку 2, возведенное в любую степень, не может стать отрицательным, однако мы сохраним эту черту нашей функции. Второе «но» относительно этого метода состоит в том, что он может давать результат и для отрицательных степеней. То есть если показатель степени отрицателен, метод вернет дробь со степенью числа 2. Не забывайте, что такие дробные значения, как 1/2, 1/4 и 1/8, - это обычные степени двух, представляющие 2-1, 2-2 и 2-3 соответственно. Эти значения могут быть выданы, если на вход подать дроби между -1,0 и 1,0. float truncateToPowerOfTwo(float input) { // преобразуем значение в целое int result = fpBits(m_float); // отбросим мантиссу result &= —((1<<23)—1); // перед возвратом преобразуем ответ в вещественное значение return (intBits(result)); }
ПРИЛОЖЕНИЕ С РУКОВОДСТВО ПРОГРАММИСТА Коды идентификации процессоров Intel Флаги характеристик ЦП (CPU Feature Flags), выдаваемые инструкцией CPUID. Источник информации - документация корпорации Intel. РАЗРЯД НАЗВАНИЕ ОПИСАНИЕ 0 FPU Процессор содержит FPU с поддержкой набора команд Intel387 для работы с плавающей запятой 1 VME Процессор поддерживает режим virtual 8086 (virtual-8086 mode extensions) 2 DE Процессор поддерживает точки останова при вводе/выводе, включая бит CR4.DE, позволяющий использовать расширения для отладки и опционально отслеживать доступ к регистрам DR4 и DR5 3 PSE Процессор поддерживает страницы размером 4 Мб 4 TSC Процессор поддерживает инструкцию RDTSC, включая бит CR4.TSD для управления доступом/привилегиями 5 MSR В процессоре имеются модельно-зависимые регистры (Model Specific Registers) и доступны инструкции RDMSR, WRMSR 6 РАЕ Имеется поддержка физических адресов длиннее 32 разрядов 7 MCE Имеется поддержка исключения 18, Machine Check Exception, а также бита CR4.MCE Enable 8 СХ8 Имеется поддержка инструкции сравнения и обмена для 8 байт 9 APIC Процессор содержит программно-доступный Local APIC 10 Зарезервированный разряд 11 SEP Указывает на поддержку процессором инструкций быстрого системного вызова (Fast System Call): SYSENTER и SYSEXIT - или ее отсутствие
336______ ПРИЛОЖЕНИЕ С РАЗРЯД НАЗВАНИЕ ОПИСАНИЕ 12 MTRR Процессор поддерживает регистры MTRR (Memory Type Range Registers), в частности регистр MTRRCAP 13 PGE Процессор поддерживает глобальный бит в записях страничного каталога (PDE, Раде Directory Entry) и таблицы страниц (РТЕ, Page Table Entry), помечающий элементы TLB, общие для различных процессов и не нуждающиеся в подавлении. Этой функцией управляет бит CR4.PGE 14 MCA Имеется поддержка архитектуры MCA (Machine Check Architecture), в частности регистра MCG_CAP 15 CMOV Процессор поддерживает инструкции CMOVcc, а если также установлен флаг наличия FPU (бит 0), то и инструкции FCMOVCC и FCOMI 16 РАТ Указывает на поддержку процессором таблицы атрибутов страниц (PAT, Page Attribute Table) или ее отсутствие. Эта функция дополняет возможности регистров MTRR, давая операционной системе возможность задавать атрибуты памяти с 4Кб-грану- лярностью в пространстве адресов с линейной организацией 17 PSE-36 Указывает на поддержку процессором страниц размером 4 Мб, способных адресовать физическую память за пределами 4Гб, или ее отсутствие. Эта возможность определяет, что верхние четыре разряда физического адреса 4-мегабайтной страницы кодируются 13 -16 битами записи в каталоге страниц 18 PSN . Процессор поддерживает 96-разрядный серийный номер процессора, и эта возможность активна 19' CLFSH Указывает на поддержку процессором инструкции CLFLUSH или ее отсутствие 20 Зарезервированный разряд 21 DS Указывает на то, что процессор имеет возможность записывать историю перехода к адресам или из адресов в буфере памяти 22 ACPI Процессор содержит внутренние модельно-зависимые регистры (MSR), дающие возможность мониторинга температуры и регулировки производительности процессора в предопределенных циклах нагрузки путем программного управления 23 MMX Процессор поддерживает расширенный набор команд для реализации технологии ММХ в рамках архитектуры Intel (Intel Architecture) 24 FXSR Указывает на поддержку процессором инструкций FXSAVE и FXRSTOR для быстрого сохранения и восстановления контекста плавающей запятой или ее отсутствие. Наличие этого бита также указывает на то, что операционной системе доступен бит CR4.OSFXSR как признак того, что процессор использует инструкции быстрого сохранения/восстановления 25 SSE Процессор поддерживает набор потоковых SIMD-инструкций (Streaming SIMD Extensions), расширяющих архитектуру Intel 26 SSE2 Указывает на поддержку процессором инструкций набора Streaming SIMD Extensions-2
РУКОВОДСТВО ПРОГРАММИСТА РАЗРЯД 1 НАЗВАНИЕ ОПИСАНИЕ 27 SS Процессор поддерживает управление конфликтными типами памяти через просмотр структуры своей кеш-памяти в поисках транзакции, выставленной на шину 28 нтт Микроархитектура процессора способна работать как несколько логических процессоров в одном физическом корпусе. Поле не является признаком того, что данный конкретный процессор реализует возможности многопоточной технологии (Hyper-Threading Technology). Чтобы определить, поддерживает ли процессор Hyper- Threading Technology, проверьте значение, возвращаемое в ЕВХ[23:.16] после выполнения CPUID с ЕАХ= 1. Если ЕВХ[23:16] содержит значение > 1, процессор поддерживает Hyper-Threading Technology 29 тм Процессор содержит цепь автоматического контроля температуры Thermal Monitor (ТСС, thermal control circuit) 30 Зарезервированный разряд 31 SBF Процессор поддерживает функцию «разрыв сигнала по FERR» (Signal Break on FERR). Сигнал FERR выдается, если прерывание задерживается, а сигнал STPCLK активен Direct3D HLSL: типы данных В вершинных и пиксельных шейдерах на языке HLSL доступны различные типы данных. СКАЛЯРНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ bool Логические значения - истина или ложь int 32-разрядное целое со знаком half 16-разрядное вещественное число половинной точности float 32-разрядное вещественное число одинарной точности double 64-разрядное вещественное число двойной точности ВЕКТОРНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ vector Вектор из четырех вещественных чисел vector<t,num> Вектор из пит значений скалярного типа t МАТРИЧНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ matrix 4 х 4-матрица из 16 вещественных значений matrix <t, row, col> Матрица значений типа /размера ггмна со/ ОБЪЕКТНЫЕ ТИПЫ ДАННЫХ ОПРЕДЕЛЕНИЕ string ASCII-строка pixelshader Объект - пиксельный шейдер Direct3D vertexshader Объект - вершинный шейдер Direct3D sampler Объект описывает применение и фильтрацию текстуры texture Объект - текстура Direct3D 12 - 1839
338 ПРИЛОЖЕНИЕ С ОПИСАНИЯ ВЕКТОРНЫХ ТИПОВ ОПРЕДЕЛЕНИЕ (# - ЗНАЧЕНИЯ от 0 до 4) bool#x# То же, что vector <bool, #>. Пример: Ьоо14 int#x# То же, что vector <mt, #>. Пример: int4 float#x# То же, что vector <float, #>. Пример: Ioat4 half#x# То же, что vector <half, #>. Пример: half4 double#x# То же, что vector <double, #>. Пример: double4 ОПИСАНИЯ МАТРИЧНЫХ ТИПОВ ОПРЕДЕЛЕНИЕ (# - ЗНАЧЕНИЯ от 0 до 4) bool#x# То же, что matrix <bool, #, #>. Пример: Ьоо14х4 int#x# То же, что matrix <int, #, #>. Пример: int4x4 float#x# То же, что matrix <float, #, #>. Пример: float4x4 hatf#x# То же, что matrix <haif, #, #>. Пример: ha!f4x4 double#x# То же, что matrix <double, #, #>. Пример' double4x4 double#x# То же, что matrix <double, #, #>. Пример- double4x4 Direct3D HLSL: выражения Ниже приведен перечень числовых и условных выражений, которые поддерживает язык описания шейдеров высокого уровня (HLSL, High-Level Shader Language) для вершинных и пиксельных шейдеров. ОПЕРАТОР ПРИМЕНЕНИЕ НАЗНАЧЕНИЕ + значение+значение Сложение по компонентам - значение-значение Вычитание по компонентам ★ значение*значение Умножение по компонентам / значение/значение Деление по компонентам % значение%3начение Взятие остатка от деления по компонентам = переменная=значение Присваивание по компонентам += переменная+=значение Сложение и присваивание по компонентам .= переменная-=значение Вычитание и присваивание по компонентам ★ _ переменная*=значение Умножение и присваивание по компонентам /= переменная/=значение Деление и присваивание по компонентам %= переменная%=значение Взятие остатка от деления и присваивание по компонентам ++ переменная++ Постфиксный инкремент каждого компонента - переменная- Постфиксный декремент каждого компонента ++ ++переменная Префиксный инкремент каждого компонента - -переменная Префиксный декремент каждого компонента
РУКОВОДСТВО ПРОГРАММИСТА ОПЕРАТОР ПРИМЕНЕНИЕ НАЗНАЧЕНИЕ - -значение Унарный минус по компонентам (инверсия знака) + +значение Унарный плюс по компонентам 1= ' значение != значение Проверка на неравенство по компонентам 1 (значение Логическое отрицание каждого компонента < значение < значение Покомпонентное «меньше» > значение > значение Покомпонентное «больше» <- значение <= значение Покомпонентное «меньше или равно» >= значение >= значение Покомпонентное «больше или равно» == значение == значение Проверка на равенство по компонентам && значение && значение Логическое «И» по компонентам II значение||значение Логическое «ИЛИ» по компонентам ?: Аоа1?значение:значение Условный оператор Direct3D HLSL: встроенные функции Далее приведен перечень встроенных функций, реализуемых HLSL для вершинных и пиксельных шейдеров. ФУНКЦИЯ ОПИСАНИЕ value abs(value а) acos(x) Абсолютная величина каждого компонента Возвращает арккосинус каждого компонента х. Каждый компонент должен находиться в диапазоне [-1,1] all(x) any(x) asin(x) Проверяет, все ли компоненты хотличны от нуля Проверяет, есть ли в х хотя бы один ненулевой компонент Возвращает арксинус каждого компонента х. Каждый компонент должен находиться в диапазоне [—pi/2, pi/2] atan(x) Возвращает арктангенс х. Возвращаемые значения находятся в диапазоне [-Pi/2, Pi/2] atan2(y, x) Возвращает арктангенс у/х. Знаки уи хслужат для определения квадранта возвращаемых значений в диапазоне [-pi, pi]. Функция atan2 вполне определена во всех точках, отличных от начала координат, даже при нулевом значении хи ненулевом у ceil(x) clampfx, min, max) Возвращает наименьшее целое, большее или равное х Ограничивает ж диапазоном [min, max] 2’
340 ПРИЛОЖЕНИЕ С ФУНКЦИЯ ОПИСАНИЕ clip(x) Отбрасывает текущий пиксел, если любой компонент х оказался меньше нуля. Эта функция может использоваться для имитации секущих плоскостей, если каждый компонент ^представляет расстояние до такой плоскости cos(x) cosh(x) cross(a, b) D3DCOLORtoUBYTE4(x) Возвращает косинус х Возвращает гиперболический косинус х Возвращает векторное произведение двух ЗО-векторов а и b Настраивает и масштабирует компоненты 40-вектора х. компенсируя отсутствие поддержки UBYTE4 на ряде аппаратных платформ ddx(x) Возвращает частную производную х по оси х экранного пространства координат ddy(x) Возвращает частную производную х по оси у экранного пространства координат degrees(x) determinant(m) distanced, b) dt>t(a, b) exp(x) value exp2(value a) faceforward(n, i, ng) floor(x) fmodfa, b) Переводит % из радиан в градусы Возвращает определитель квадратной матрицы m Возвращает расстояние между двумя точками а и b Возвращает скалярное произведение двух векторов а и b Возвращает экспоненту % по основанию е Возвращает экспоненту каждого компонента по основанию 2 Возвращает -n * sign(dot(i, ng)) Возвращает наибольшее целое, меньшее или равное х Возвращает вещественный остаток /от деления а / Ь, такой что а = i * b + f, где i - целое, /имеет тот же знак, что и х, а абсолютная величина f меньше абсолютной величины b frac(x) Возвращает дробную часть / параметра х, такую что /- значение, большее или равное 0 и меньшее 1 value frc(value a) frexp(x, out exp) Дробная часть каждого компонента Возвращает мантиссу и порядок х. Сама frexp возвращает мантиссу, а порядок сохраняется в выходном параметре ехр. Если значение хравно 0, то функция возвращает 0 и в мантиссе, и в порядке числа fwidth(x) isfinite(x) isirif(x) Возвращает abs(ddx(x))+abs(ddy(x)) Возвращает истину, если значение ^конечно, в противном случае - ложь Возвращает истину, если значение % равно +INF или -INF, в противном случае - ложь isnan(x) Возвращает истину, если значение % равно NAN или QNAN, в противном случае - ложь
РУКОВОДСТВО ПРОГРАММИСТА 341 ФУНКЦИЯ ОПИСАНИЕ ldexp(x, exp) float len(value a) length(v) lerp(a, b, s) Возвращает x* 2exp Длина вектора Возвращает длину вектора v Возвращает а + s(b - а). Функция реализует линейную интерполяцию а и Ь, при этом возвращаемое значение равно а при s, равном 0, и b при s, равном 1 litfndotl, ndoth, m) Возвращает вектор освещения (рассеянного, диффузного, зеркального, 1): рассеянное = 1; диффузное = (ndotl < 0) ? 0: ndotl; зеркальное = (ndotl < 0) || (ndoth < 0) ? 0: (ndoth * m) log(x) Возвращает логарифм % по основанию е. Если ^отрицательно, то функция возвращает неопределенное значение. Если значение хравно 0, функция возвращает +INF Iog10(x) Возвращает логарифм % по основанию 10. Если ^отрицательно, то функция возвращает неопределенное значение. Если значение х равно 0, функция возвращает +INF Iog2(x) Возвращает логарифм % по основанию 2. Если % отрицательно, то функция возвращает неопределенное значение. Если значение % равно 0, функция возвращает +INF max(a, b) min(a, b) modf(x, out ip) Выбирает большее из а и b Выбирает меньшее из а и b Выделяет в хдробную и целую части, каждая из которых имеет знак х. Возвращаемым значением функции становится дробная часть х со знаком. Целая часть сохраняется в выходном параметре ip mul(a, b) Возвращает произведение матриц а и Ь. Если а - вектор, он трактуется как вектор-строка. Если b - вектор, он трактуется как вектор-столбец. Внутренние размерности: число столбцов а и число строк b должны совпадать. Результат имеет размеры: число строк ах число столбцов b noise(x) normalize(v) . Еще не реализована Возвращает нормированный вектор v / length(v). Результат не определен при нулевой длине и pow(x, y) radians(x) reflectfi, n) Возвращает ху Переводит х из градусов в радианы Возвращает вектор отражения v при заданном направлении падающего луча I и нормали к поверхности п, такой что v = I - 2 * dot(i, n) * n refractfi, n, eta) Возвращает вектор рефракции v при заданном направлении падающего луча i, нормали к поверхности п и относительном индексе рефракции eta. Если угол между I и п слишком велик для данного eta, refract возвращает (0,0,0) round(x) Округляет хдо ближайшего целого
342 ПРИЛОЖЕНИЕ С ФУНКЦИЯ ОПИСАНИЕ rsqrt(x) saturate(x) sign(x) Возвращает 1 / sqrt(x) Ограничивает xдиапазоном [0,1] Вычисляет знак х. Возвращает -1, если х меньше 0; 0, если значение х равно 0; и 1, если х больше нуля sin(x) sincosfx, out s, out c) Возвращает синус х Возвращает синус и косинус х. sin(x) сохраняется в выходном параметре s. cos(x) сохраняется в выходном параметре с sinh(x) smoothstepfmin, max, x) Возвращает гиперболический синус х Возвращает 0, если х< min. Возвращает 1, если х> max. Возвращает полученное гладкой эрмитовой интерполяцией значение от 0 до 1, если х принадлежит диапазону [min, max] value sqrt(value a) step(a, x) tan(x) tanh(x) tex1D(s, t) tex1D(s, t, ddx, ddy) Квадратный корень каждого компонента Возвращает (х >= а) ? 1: 0 Возвращает тангенс х Возвращает гиперболический тангенс х Просмотр 1 D-текстуры, s - объект типа sampler или samplerl D. t - скаляр Просмотр 1 D-текстуры с производными, s - объект типа sampler или samplerl D. t, ddx и ddy - скаляры text Dprojfs, t) Просмотр проективной 1 D-текстуры, s - объект типа sampler или samplerl D. t - 40-вектор. Перед просмотром t делится на последний из своих компонентов tex1Dbias(s, t) Просмотр смещенной 1 D-текстуры, s - объект типа sampler или samplerl D. t - 40-вектор. Перед просмотром mip-уровень смещается на t.w tex2D(s, t) Просмотр 20-текстуры, s - объект типа sampler или sampler2D. t - координата 20-текстуры tex2D(s, t, ddx, ddy) Просмотр 20-текстуры с производными, s - объект типа sampler или sampler2D. t, ddx и ddy - двухмерные векторы tex2Dproj(s, t) Просмотр проективной 20-текстуры, s - объект типа sampler или sampler2D. t - 40-вектор. Перед просмотром t делится на последний из своих компонентов tex2Dbias(s, t) Просмотр смещенной 20-текстуры, s - объект типа sampler или sampler2D. t - 40-вектор. Перед просмотром mip-уровень смещается на t.w tex3D(s, t) Просмотр объемной ЗО-текстуры. s - объект типа sampler или sampler3D. t - координата ЗО-текстуры tex3D(s, t, ddx, ddy) Просмотр объемной ЗО-текстуры с производными, s - объект типа sampler или sampler3D. t, ddx и ddy - трехмерные векторы
РУКОВОДСТВО ПРОГРАММИСТА 343 ФУНКЦИЯ ОПИСАНИЕ tex3Dproj(s, t) Просмотр проективной ЗО-текстуры. s - объект типа sampler или sampler3D. t - 40-вектор. Перед просмотром t делится на последний из своих компонентов tex3Dbias(s, t) Просмотр смещенной 30-текстуры, s - объект типа sampler или sampler3D. t - 40-вектор. Перед просмотром mip-уровень смещается на t.w texCUBEfs, t) Просмотр кубической ЗО-текстуры. s - объект типа sampler или samplerCUBE. t - координата ЗО-текстуры texCUBE(s, t, ddx, ddy) Просмотр кубической ЗО-текстуры с производными, s - объект типа sampler или samplerCUBE. t, ddx и ddy - трехмерные векторы texCUBEprojfs, t) Просмотр проективной кубической ЗО-текстуры. s - объект типа sampler или samplerCUBE. t - 40-вектор. Перед просмотром t делится на последний из своих компонентов texCUBEbias(s, t) Просмотр смещенной кубической ЗО-текстуры. s - объект типа sampler или samplerCUBE. t - 4-мерный вектор. Перед просмотром пир-уровень смещается на t.w transpose(m) Транспонирует матрицу т. Если исходная матрица имеет размер число строк х число столбцов, то результат имеет размер число столбцов х число строк Direct3D HLSL: настройки шаблона Далее приведен список значений, которые могут быть заданы при построении текстурных шаблонов HLSL-шейдеров. СТАТУС ШАБЛОНА ТИП ДОПУСТИМЫЕ ЗНАЧЕНИЯ AddressU dword WRAP= 1, MIRROR = 2, CLAMP = 3, BORDER = 4, MIRRORONCE = 5 AddressV dword To же, что в AddressU AddressW dword To же, что в AddressU BorderColor 11oat4 Значение цвета; вектор, содержащий значения RGBA в диапазоне 0-1 Magfilter dword NONE = 0, POINT = 1, UNEAR = 2, ANISOTROPIC = 3, PYRAMIDALQUAD = 6, GAUSSIANQUAD = 7
344 ПРИЛОЖЕНИЕ С СТАТУС ШАБЛОНА ТИП ДОПУСТИМЫЕ ЗНАЧЕНИЯ MinFilter dword To же, что в MagRIter MipRIter dword То же, что в MagRIter MaxAnisotropy dword Наибольшая анизотропия; по умолчанию 1 MaxMipLevel int Наибольшая глубина множественных отображений в диапазоне 0-п, где п - число наличных множественных отображений. Самая крупная текстура имеет индекс 0. Самая мелкая - (п-1) MipMapLodBias float Величина смещения для выбранной глубины множественных отображений. По умолчанию 0,0 SRGBTexture bool True (ненулевое значение), если текстура сэмплирована в формате sRGB (с гамма-коррекцией 2,2). Более подробно о гамма-коррекции см. документацию к DirectX SDK Elementindex dword Если шаблон содержит текстуру из нескольких элементов, указывает на индекс элемента в работе. По умолчанию 0
ПРИЛОЖЕНИЕ D РЕКОМЕНДУЕМАЯ ЛИТЕРАТУРА Ниже приведен перечень источников, ссылки на которые делались в этой книге, а также указан ряд дополнительных материалов, рекомендуемых нами для дальнейшего изучения. Математика Lengyel, Eric. Mathematics for 3D Game Programming & Computer Graphics. Charles River Media, 2002. Gribb, G., and K. Hartmann. «Fast Extraction of Viewing Frustum Planes from the World- View-Projection Matrix» (работа доступна по адресу www2.ravensoft.com/users/ggribb/ plane%20extraction.pdf). Программирование 30-задач Deloura, М. Game Programming Gems. Charles River Media, Inc., 2000. Deloura, M. Game Programming Gems 2. Charles River Media, Inc., 2001. Treglia, D. Game Programming Gems 3. Charles River Media, Inc., 2002. Watt, A. 3D Computer Graphics. Addison-Wesley, 1993. Watt, A. and Watt, M. Advanced Animation and Rendering Techniques. Addison-Wesley, 1992. Microsoft DirectX9 Development FAQ (ресурс доступен по адресу http://msdn.mi- crosoft.com/ Ubrary/en-us/dndxgen/html/directx9devfaq.asp). Engel, W. ShaderX. Wordware Publishing, Inc., 2002. Wenzel, C. «Ocean Scene» (работа доступна по адресу http://meshuggah.4fo.de/Ocean- Scene.htm).
346 ПРИЛОЖЕНИЕ D Научные исследования Perhn, К «Making Noise Tutorial and History of the Noise Function» (работа доступна по адресу www noisemachine com). Perlin, K. «Improving Noise». Computer Graphics, Vol. 35, No. 3 (работа доступна по адресу http //mrl nyu edu/~perlin/paper445 pdf). Preetham, A J , P Shirley, and В Smits «А Practical Analytic Model for Daylight» Sig- graph proceedings 1999 (работа доступна по адресу wwwes Utah edu/vissim/papers/siinsky). Hoffman, N., and A. J. Preetham. «Rendering Outdoor Light Scattering in Real Time» (работа доступна по адресу wwwati com/developer/dx9/ATI-LightScatteringpdf) Fournier, A , and W. T. Reeves. «А Simple Model of Ocean Waves». Computer Graphics, Vol. 20, No. 4, 1986, p 75-84 Peachey, D «Modeling Waves and Surf». Computer Graphics, Vol 20, No. 4, 1986, p. 65-74. Mastin, G A., P A Watterger, and J. F Mareda. «Fourier Synthesis of Ocean Scenes». IEEE CG&A, March 1987, p. 16-23. Другие полезные Web-сайты Web-сайт автора книги: www mightystudios com Web-страница для разработчиков компании ATF wwwati сот/developer/ Web-страница для разработчиков компании NVIDIA: http //developer nvidia сот/ FlipCode. wwwflipcode com GameDew www gamedev net GamaSutra: www gamasutra com Данные Геологической службы CHIA (USGS): wwwusgs gov/ Проект «Виртуальный ландшафт» (Virtual Terrain Project): www vterrain org Программы и утилиты 3DEM, Visualization Software, LLC' программа визуализации: www visuahzationsoft- ware com/ 3dem html T2' программный генератор текстур: wwwpetra demon co uk/Games/texgen html
ПРИЛОЖЕНИЕ Е ОБЗОР КОМПАКТ-ДИСК/! Компакт-диск, вложенный в книгу «Создание 3D-ландшафтов в реальном времеш с использованием C++ и DirectX 9», содержит все файлы, необходимые для компиляци: движка, описанного в самой книге. Кроме того, на CD-ROM находятся исходные и испол нимые файлы демонстрационных программ, упоминаемые в каждой главе. На диск можно найти и все модели, текстуры и файлы эффектов. Особую благодарность з; помощь в создании ряда моделей и текстур с этого диска мы выражаем Кристофер; Барретту (Christopher Barrett). Описание папок SOURCE: В этой папке находятся все исходные коды. Для каждого элемента движк; и каждой отдельной демонстрационной программы выделена собственная дочерняя папка Папка с именем bin содержит предварительно откомпилированный исполняемый модул! каждой демо-программы, а также все медиафайлы, необходимые для их запуска. DIRECTX: Полный набор средств DirectX 9.0 SDK. Для установки SDK следуйте инструкциям в этой папке. TOOLS: Сюда мы включили набор инструментов, призванных помочь вам в создали: собственного ландшафта. Для загрузки последних версий этих программ, если они уже соз даны, зайдите на Web-страницу того или иного продукта. Первой программой является вариант утилиты Т2 Кита Дитчберна. Этот полезны: генератор текстур способен строить текстурные карты для любого ландшафта по задан ному набору параметров. Более подробную информацию о Т2 можно найти по адрес; www. petra. demon, со. uk/Games/texgen.html. Второе приложение - 3DEM компании Visualization Software, LLC. Эта программ: умеет преобразовывать ландшафтную информацию, взятую из реального мира, в целы:
348 ПРИЛОЖЕНИЕ Е ряд исходных форматов, пригодных для применения в ландшафтном движке для карт высоты. За более детальной информацией и поддержкой продукта можно обратиться по адресу www.visualizationsoftwaie.com. На этом же Web-сайте приводится список адре- сов загрузки открытых ландшафтных данных для применения их с 3DEM. Системные требования Windows 2000/ХР: □ Процессор Pentium III, 1ГГц и более □ DirectX 9-совместимая видеокарта с поддержкой программируемых вершинных и пик- сельных шейдеров с возможностью их аппаратного ускорения (NVIDIA GeForce 3 или выше, ATI Radeon 8500 или выше) □ CD-ROM/жесткий диск □ 128 Мб ОЗУ (рекомендуется 256 Мб) □ 500 Мб свободного дискового пространства для установки DirectX SDK, а также при- меров исходного кода и поставляемых утилит Программные требования Для правки и компиляции поставляемого исходного кода требуется среда Microsoft Visual Studio.NET или Microsoft Visual Studio 6.0. Другие редакторы и компиляторы хотя и могут быть с ней совместимы, в тестировании заняты не были. Установка Чтобы воспользоваться компакт-диском, убедитесь, что ваша система удовлетворяет, по крайней мере, минимальным системным требованиям. Каждая из программ имеет соб- ственные инструкции по установке, и в случае возникновения любых проблем с инсталля- цией вам следует обращаться непосредственно к ее разработчику. Папку с исходным кодом для редактирования в выбранной вами программе вы можете напрямую скопиро- вать на свой жесткий диск. Обновления и исправления опечаток Для получения перечня обновлений и опечаток, найденных в книге и прилагаемых к ней исходных кодах, зайдите на Web-сайт издательства Charles River Media (www.char- lesriver.com) и на Web-сайт автора (www.mighiystudios.com).
Предметный указатель # ... (многоточие), переменное количество аргументов, 313 bmp-файлы, 35 dds-файлы, 35 1 .dib-файлы, 35 •fx-файлы (эффектов), 34 .jpg-файлы, 35 .png-файлы, 35 .tga-файлы, 35 •х-файлы, 34 : (двоеточие) и семантика, 55-56 16-байтное выравнивание памяти, 16 32-разрядные значения с плавающей запятой, 323 3D Computer Graphics [Уатт (Watt)[, 29 3DEM, Visualization Software LLC, 124, 347 A Alias|Wavefront Maya, 46 c CD-ROM, как пользоваться прилагаемым диском, 347-348 CdnfirmDevice, 240-241 D D3DRes.h, 18 Dev Partner Profiler, Compuware, 314 Direct3D Sample Application Framework, 17-19 введение, 15 основные объекты, 34-36 система координат, 20-22 DirectX 9.0 графика в DirectX. См Direct3D инструментарий разработчика (SDK), 6-7, 15, 17 математическая библиотека D3DX, 19-20 файлы Samples Framework, 17-18 Discreet 3ds max, 46 Dxreadme.htm, 16 F Fxc.exe, 31, 61 G Gaia. См. Игровой движок Game Programming Gems [Рабин (Rabin)], 302 Game Programming Gems 2 [Притчард (Pritchard)], 111 Game Programming Gems 2 [Снук (Snook)], 187 I Intel коды идентификации ЦП, 335—337 поддержка процессоров, 294, 295 м Microsoft DirectX. См. DirectX 9.0 Microsoft Visual Stndio. См. Visual Studio Microsoft Windows распознавание версии, 293 системные требования, 7 R RandomChannelNoise, 211 s Sample Application Framework, 17-19 T TreadMarks, игра производства Longbow Digital Arts, 157,163
352 Предметный указатель Длина векторов, 23-24 Дорожки KeyTrackWeight, 50 значение весового коэффициента, 49 приоритет, 48—49 функция KeyTrackSpeed, 50 функция-член SetTrackDesc, 48 Доступная физическая память, 292 Дуглас Э. С. (Douglas, A. S.), 5 Дюшено, Марк (Duchaineau, Mark), 155,163 Е Единичные векторы, 24 3 Заголовочные (,Ь-)файлы DirectX Samples Framework, 17-18 Загрузка мешей, 36-37 текстур, 36 файлов эффектов, 41—43 Затухание света, 248 Значение смещения, метод ROAM, 156 шероховатости, 127 Значения метрик ошибки для метода ROAM, 156-157, 160, 170 для мозаичного ландшафта, 190-192 для сетки вершин, 180-182 И Игровой движок cGameHost, 67, 69-70, 235, 240 базовый класс ресурсов, 79-81 время и управление приоритетом потоков, 69-70 главное приложение, 67-70 индексные и вершинные буферы, 83-85 основы Gaia, движок ЗО-ландшафта, 65-66 пулы данных, 70-75 редактор моделей, 102 ресурсы метода рендеринга, 82-83 ресурсы модели, 85-88 ресурсы-текстуры и материалы поверхностей, 81 узлы и объекты сцены, 88 управление разделяемыми ресурсами данных, 76-79 элемент визуализации и очередь на рендеринг, 89-101, 143, 148,254 Идентификация процессора коды, 335-337 реализация, 294-295, 298-300 Иерархия кадров, 46, 47 мешей, 46, 47 моделей, 43—46 См. также Квадрадеревья Импорт текстур, 35 Индексные буферы блочный ландшафт, 170-179 взаимосвязанная мозаика, 187, 188, 190 ландшафтная геометрия, 140-142 метод ROAM, 165-167 описание, 83-85 Информация Национального управления по исследованию океанов и атмосферы (NOAA), 124 о процессоре, 294-296, 298-301 о системе, 291-301 Исходные (.срр-)файлы DirectX Samples Framework, 17-18 й Йенсен, Ласс Стафф (Jensen, Lasse Staff), 266 К Кадры иерархия, 46-47 контейнеры мешей и, 43—46 функция FrameMove, 68 Камеры класс cCamera, 221 класс CD3DCamera, 18 матрица небесной оболочки, 221-222 поле зрения, 106-108, 117-122 светорассеяние на объективе, 229-232 Канал вывода в файл, 311
Предметный указатель 351 Векторы D3DXVec2Cross. 27 D3DXVec3Cross, 27 D3DXVECTOR, 22 векторное произведение, 27 векторные типы данных, 54, 337 вращение кватернионов, 31-32 нормирование, 23-24 описания векторных типов, 54, 337 скалярное произведение, 24—27 точки и, 22-23 функция D3DXVec2Dot, 24 функция D3DXVec2Normalize, 24 функция D3DXVec3Dot, 24 функция D3DXVec3Normalize, 24 Величина векторов, 23 Венцель, Карстен (Wenzel, Carsten), 266 Версия операционной системы, распознавание, 293 Вершинные буферы блочный ландшафт, 170-179 в методе ROAM, 166 взаимосвязанная мозаика, 187, 190 ландшафтная геометрия, 142-148 описание, 83-85 Вершинные и пиксельные шейдеры, 43, 51, 52-53 Вершинные шейдеры встроенные в файлы эффектов, 43, 61-64 деформация фрагментов, 260-261 доступные типы данных, 337-338 затухание и внутреннее рассеяние света, 250-252 небесный купол, 226-227 объект vertexshader, 61 пример программы, 52-53 Си также Язык описания шейдеров высокого уровня (HLSL) Взаимосвязанная ландшафтная мозанка, 187-192 Видео аппаратные требования, 6-7, 348 память и текстуры, 196 Видеокарты отображение неровностей и устаревшая аппаратура, 242 файлы эффектов и, 37, 38 Визуализация. См. Рендеринг Внеинтерьерные сцены. См. Сцены, внеинтерьерные Внутреннее рассеяние света, 246, 248-253 Вода. См. Океанские воды Воздушная перспектива н освещение, 245, 246 Воспроизведение скорость, 291 управление, 47 48 Вращение блокировка, 32 векторов, 27 кватернионов, 31-32 Вывод всех таймеров, 319 строки отладки, 310 Выравнивание на границу 16 байт, 16 Выражения, 56-57, 338-339 Высота источники данных, 124 смешивание текстур и, 202-203 Вятт, Роб (Wyatt, Rob), 294 Г Гауссов генератор случайных чисел, 267-269 Голиас, Роберт (Golias, Robert), 266 Горизонт, 228 д Данные о системе, 291-301 Двоеточие (:) и семантика, 55-56 Демонстрационная программа EffectEdit, 63 Дерево метрик ошибок, 182-184 Деформация фрагментов, 260-261 Диалоги, D3DSettingsDialog, 17-18. См. также Сообщения Дитчберн, Кит (Ditchburn, Keith), 195
350 Предметный указатель V Visual Studio системные требования, 6, 348 установка Visual Studio.NET, 16 VTune, Intel, 314 z Z-буфер и рассеянное освещение, 236-239 А Абсолютное значение и бит знака, 326 Альфа-канал и карты неровностей, 239-241 Анимация ID3DXAnimationController, 47-49 ID3DXAnimationSet, 47 48 воды,265-275 воспроизведение, 47-48 облаков, 227-229 по ключевым кадрам, 47, 50 приоритет дорожек, 48-49 скелетная, 46-50 скорость воспроизведения, 291 Аппаратное обеспечение работа на унаследованной технике, 60-61, 213,242 требования, 6-7, 348 Библиотека расширений Direct3D (D3DX) D3DXFRAME, 34, 85-86 D3DXFRAME_DERIVED, 86 D3DXMaterial, 35 D3DXMATRIX, 31 D3DXMATRIXA16, 31 D3DXMESHCONTAINER, 34, 86 D3DXMESHCONTAINERDER1VED, 86 D3DXQuatemion, 33 D3DXVECTOR, 22 математическая библиотека, 19-20 матрицы, 28-31 скалярное произведение векторов, 24—27 точки и векторы, 22-23 функция D3DXVec2Cross, 27 функция D3DXVec2Dot, 24 функция D3DXVec2Normalize, 24 функция D3DXVec3Cross, 27 функция D3DXVec3Dot, 24 функция D3DXVec3Normalize, 24 Библиотеки Math языка С, стандартная, 322 Microsoft Direct3D Extension Library (D3DX), 7 Microsoft Foundation Classes (MFC), 102 математическая, 19-20 утилит Direct3D. См. Библиотека расширений Direct3D (D3DX) Бит знака, 326-327 Битовые флаги, 281-284, 309 Битовые шаблоны квадрадеревья, 113 октадеревья, 114-115 Блокировка вращения, 32 Блочный ландшафт мозаичное представление, 179-187 описание, 170-187 смена уровней LOD, 192-193 управление блоками геометрии, 172-179 Булевы значения и битовые флаги, 281-284, 309 Буферы D3DXBUFFER, 61 базовый цвет и рассеянное освещение, 236-239 блочный ландшафт, 170-179 взаимосвязанная мозаика, 187-190 динамические, 84 индексные и вершинные, 83-85 ландшафтная геометрия, 140-148 экранная геометрия в ROAM, 165-167 Быстрое преобразование Фурье (FFT), 265-272 Бэр, Ральф (Baer, Ralph), 5 В Ввод, устройство CD3DArcBall, 18 Векторное произведение векторов, 27
Предметный указатель 353 Карты D3DXComputeNormalMap, 136 generateNormalMap, 137 высотные. См. Карты высот горизонт, 228 неровностей, 239-245 отображение на текстуру, 260-261 среды кубической формы, 218-220 Карты высот buildHeightandNormalTables, 136 как входные данные о ландшафте, 123-125 обработка данных, 135-139 процедуры создания, 125 формирование островов, 263 Квадрадеревья быстрый поиск, 115-116 добавление третьего измерения, 114-115 медленный поиск, 117-122 основы использования, 109-110 размера, равного степени числа 2, 112-113 расширение, 111-114 уровни и битовые шаблоны, 113 функции уведомления, 116 Кватернионы вращение, 31-32 объект D3DXQuatemion, 33 Класс CD3DApplication, 18-19 CD3DCamera, 17 CD3DEnumeration, 17, 18 CD3DFont, 17, 18 CD3DSettings, 17, 18 CD3DSettingsDialog, 17-18 String, 288-290 с единственным экземпляром, 285-288 Классы DirectX Samples Framework, 17-18 Ключевое слово extern, 55 tn, 55 out, 55 Ключевые кадры, 47, 50 Коды ошибок, 307-308 Компиляторы D3DXCompileShader, 61 fxc.exe, 31,61 опция/Fc, 61 поддержка 16-байтного выравнивания, 16 производства компаний, отличных от Microsoft, 16 утверждения времени компиляции, 309-310 Компоненты библиотеки ядра, 14 Конкатенация матриц, 31 Константные переменные, 55 Контейнеры мешей, 43-46 Контрольные точки, 301-312 Координаты векторы и точки, 22-23 карты высот, 123 левые и правые системы, 20-22 по оси х, 20-22 по оси у, 20-22 по оси z, 20-22 Косинус и скалярное произведение векторов, 24-27 Л Ландшафтная геометрия cTerrain, 148-152, 154, 200 cTerrainSection, 148-152, 154, 160 базовые классы, 139 блочный ландшафт, 170-187 вершинные буферы, 142-148 взаимосвязанная ландшафтная мозаика, 187-192 гладкость, 125 демонстрация базового ландшафта, 152-153 значение шероховатости, 127 индексные буферы, 140-142 карты высот как входные данные, 123-125 нормали к поверхностям, 136-139, 239, 244 обработка данных высотных карт, 135-139 октавы, 135 отображение объектов на ландшафт, 260-261 процедурные карты высот, 125 рендеринг участков ландшафта, 148-152 смена уровней LOD, 192-193
354 Предметный указатель смещение средней точки, 126-127 фрактальное броуновское движение (fBM), 134-135 шум Перлина, 127-135 См также Метод ROAM Ландшафтная информация агентства NASA, 124 Геологической службы США (USGS), 124 Ландшафтный движок. См. Игровой движок Левая система координат, 20-22 м Мак-Нелли, Сьюмас (McNally, Seumas), 157 Макрос для переброски знака, 31, 329 для профилирования области видимости объекта, 320 Маски z-маски и октадеревья, 116 отображение неровностей, 243-244 Масштабирование векторов, 24 Математика, математическая библиотека D3DX, 19-20 Материалы, базовые объекты, 35 Матрицы D3DXMATRIX, 31 D3DXMATRIXA16, 31 анимационные данные, 47—48 вида небесной оболочки, 221-222 конкатенация, 31 описания типов, 54, 337 типы данных, 54, 337 умножение, 29-30 форматы, 29 Метод видонезависимых прогрессивных мешей, 36 ограничения точности вещественных чисел, 331 Метод ROAM (Real-Time Optimal Adapting Mesh) buildTriangleList, 166 cRoamTerrain, 160-161 cRoamTerrainSection, 160-162, 166, 167 cTnTreeNode, 160-162 алгоритм, описание, 155-157 вершинные и индексные буферы, 165-168 значение смешения, 156 значения расстояния и масштаба, величина предела, 157 метрика ошибок, 156-157, 160, 170 построение геометрии отображения, 165-168 реализация, 160-165 решение о разбиении, 157-159 смена уровня LOD, 192 См также Ландшафтная геометрия Меши D3DXLoadMeshFromFile, 37 D3DXLoadMeshHierarchyFromX, 46, 47 ID3DXMesh, 35, 36, 44, 45, 255 ID3DXPatchMesh, 44 ID3DXPMESH, 36, 44 ID3DXSkmInfo, 44, 46 ID3DXSPMESH, 36 загрузка, 36-37 контейнеры мешей, 43—46, 48 объекты CD3DMesh, 17 объекты, 17, 35-36 отекстуренные, 46-50 подмножества, 37 прогрессивные, 36 управление уровнями детализации (LOD), 254-255 упрощающие, 36 функция DrawSubset, 37 Многобайтные наборы символов (MBCS), 288 Модели х-файлы (моделей), 34 cModelResource, 254 cSceneModel, 254 билборды, 255-257 загрузка и отображение, 36-37 иерархия, 43^16 редактор, 102 ресурсы, 85-88 файлы цифровых карт высоты (DEM), 124 См также Игровой движок
Предметный указатель 355 Мозаичное представление блочного ландшафта, 179-187 ландшафта в методе ROAM, 167-168 н Наборы Unicode-символов, 288 Наклон и смешивание текстур, 202-203 Начало системы координат, 20 Нормали к поверхностям, 136-139, 239, 244 О Оборачивание адресов текстур, 197 Общая физическая память, 292 Общее состояние памяти, 292 Объекты CD3DMesh, 17 загрузка и отображение моделей, 36-37 классов D3DX, 35 контейнеры кадров и мешей, 43—46 основные, обзор, 34-36 скелетная анимация и отекстуренные меши, 46-50 сцены, 88 типы данных, 54 файлы эффектов, применение, 37-43 Ограничение адресов текстуры, 197 диапазона вещественных чисел, 331-334 Однобайтовый набор ANSI-символов (SBCS), 288 Океанские воды cOceanManager, 271 анимация воды, 265-275 введение в состав ландшафта, 262-264 Окклюзия, 231-232 Октавы, 135 водная мозаика, 264-265 рендеринг воды, 276-280 Октадеревья z-маска, 116 описание, 109, 114 Оператор switch, 310 Операторы и выражения, 56, 338-339 Операционная система, распознавание версии, 293 Описания типов, 53, 54 Освещение cLightManager, 247, 252 cLightScattering, 252 computeSunLightColor, 247 внутренне отраженный свет, 246, 248-253 воздушная перспектива, 245, 246 затухание света, 248 менеджер,247 наружное, 245-253 рассеянное, 235—239 Оси, система координат, 20-22 Особенности Microsoft Windows NT, 293 Остров, формирование, 262-264 Отекстуренные меши, 46-50 Отладка Just-In-Time, 301 канал отладки, 311 обработчик отладочных сообщений, 313-314, 319 текстовые сообщения для отладки, 310-314 утверждения времени компиляции, 309-310 утверждения, предупреждения и комментарии, 301-308 Отображение неровностей, 239-245 поддержка отображения и CD3DEnumeration, 17, 18 среды кубической формы, 218-220 функция ошибки, 306 Си. также Рендеринг Очередь на рендеринг, 89-101, 143, 148,254 с приоритетом,163 п Память ее выделение и хранение строк, 290 информация,о состоянии и системная информация, 292 кватернионы и данные об их вращении, 32
356 Предметный указатель поддержка компилятором 16-байтного выравнивания, 16 пробуксовка, 196 прямой доступ (DMA), 84 хранение матриц и развертка матрицы по строкам, 29 Папка \Include, 16 \Lib, 16 Пейзаж. См. Ландшафтный движок; Ландшафтная геометрия Переменное количество аргументов, многоточие (...), 313 Переменные volatile, 55 в языке HLSL, 53-56 пользовательские, распознаваемые cEffectFile, 82 Перлин, Кен (Perlin, Кеп), 127 Пикселы и визуальное качество, 194 Пиксельные шейдеры встроенные в файлы эффектов, 43, 61-64 доступные типы данных, 337-338 затухание и внутреннее рассеяние света, 249-250 наложение рельефных текстур, 242, 244—245 небесный купол, 228-229 объект pixelshader, 61 пример программы. 58-59 См также Язык описания шейдеров высокого уровня (HLSL) Плоскости класс cPlane3d, 117 создание травы, 258-259 тест «плоскость - прямоугольник», 119-122 Поверхности cSurfaceMaterial, 222, 223 D3DXLoadSurfaceFromSurface, 207 интерполяция, 201-202 материалы, 81, 86 нормали к поверхностям, 136-139, 239, 244 текстур См Текстуры Повторное нормирование и карта неровностей, 242-245 Поддержка набора команд Streaming S1MD Extensions (SSE), 16, 294, 295 процессоров AMD, 294 Подмножества в мешах, 37 Поиск быстрый, в квадрадеревьях, 115-116 лучшей реализации в файле эффектов, 41 медленный, в квадрадеревьях, 117-122 ошибок, 309-310 Поле зрения камеры, 106-108, 117 22 Правая система координат, 20-22 Преобразование convertToBumpMask, 243 вещественных чисел в целые, 327-329 чисел в формат с плавающей запятой, 323-326 Преобразование вещественных чисел в формат с плавающей запятой, 323, 325 в целые, 327 Приведение чисел в двоичной системе к вещественному формату, 324-325 Приложения CD3DApphcation, 18-19 главное приложение Gaia, 67-70 таймер, 314-315, 319 Приоритеты время и потоки программы, 69-70 дорожек, 48-49 очереди с приоритетом, 163 при смешении, 48 Притчард, Мэтт (Pritchard, Matt), Game Programming Gems 2, 111 Притэм, Э. Дж. (Preetham, A. J.), 246, 250 Пробуксовка, 196 Проверка принадлежности полю зрения, 119-122 Программа Т2 для работы с текстурами, 195-196, 200, 347 Программные требования, 6-7, 348 Прогрессивные меши, 36, 36 Проецирование векторов, 26-27 Пространства имен, 65-66 Прямой доступ к памяти (DMA), 84
Предметный указатель 357 Пул данных добавление и удаление элементов, 73-75 разделяемые ресурсы, 76-79 создание, 70-75 Пулы данных, 70-75 Р Разбиение пространства, 104. Си. также Сцена Разбиения прогрессивных мешей, 36 треугольников и метод ROAM, 157- 159 Развертка матрицы по столбцам, 29 по строкам. 29 Разделяемые (совместно используемые) ключевое слово shared, 55 ресурсы данных. 76-79 Рассеяние света модель Ми, 248, 249, 252 модель Релея, 248, 249, 252 Рассеянный свет, 235-239 Расчет расстояния, 27 Рекомендуемая литература, 345-346 Рендеринг воды, 276-280 меши, 36-37 неба. См. Рендеринг неба очередь, 89-101. 143, 148, 254 проходы, 214 ресурсы метода. 82-83 участков ландшафта, 148-152 файлы эффектов и процедуры рендеринга, 37 43 элемент, 90-101, 143 Рендеринг неба cEffectFile, 222, 223 createCubeTexture, 220 cSurfaceMaterial, 222, 223 uploadCubeFace, 220 анимация облаков, 227-229 интерфейс cTexture, 220 класс cCamera, 221 класс камеры и матрица вида, 221-222 метод «небесной оболочки», 218-225 небесный купол, 225-227 окклюзия, 231-232 отображение среды кубической формы, 218-220 светорассеяние на объективе, 229-232 файлы эффектов. 223-225, 226-227 фоновое изображение, 228-229 Ресурсы cModelResource, 254 cResourcePoolItem, 76 disableResource, 80 restoreResource, 80 базовый класс ресурсов, 79-81 код ресурса, 78-79 метод рендеринга, 82-83 модели, 85-88 пулы данных, 70-75 текстуры и материалы поверхностей, 81 управление разделяемыми данными, 70, 76-79 Роббинс, Джон (Robbins, John), «Bug Slayer», Microsoft System Journal, 302 c Светорассеяние на объективе видеокамеры, 229-232 Сглаживание текстур. См. Ландшафтная геомез рия Секреты плавающей запятой бит знака, 326-327 блок FPU, 322 ограничение диапазона вещественных чисел, 331-334 ограничение точности, 329-331 округление до значения степени числа 2, 334 организация данных в формате с плавающей запятой, 323- 326 преобразование в целые, 327-329 Секторы, 107-108. См. также Квадрадеревья Семан 1ика, 55 Системные требования, 6-7, 348
358________ Предметный указатель Системный канал отладки, 312 Скалярное произведение векторов, 24-27 Скалярные типы данных, 54, 337 Скелетная анимация, 46-50 Скорость процессора, 295-296,300-301 Служебные классы данные о системе, 291-301 класс с единственным экземпляром, 285-288 строки, 288-290 текстовые сообщения для отладки, 310-314 управление битовыми флагами, 281-284, 309 утверждения, предупреждения и комментарии, 301-308 хронометраж кода, 314-320 Случайный ландшафт. См. Текстуры Смена уровней LOD, 192-193 Смешение D3DXLoadSurfaceFromSurface, 207 KeyPnontyBlend, 49 SetPnontyBlend, 49 карты неровностей, 242-244 текстуры в clmage, 207, 211 текстуры поверхностей, 197-211 Снук, Грег (Snook, Greg), Game Programming Gems 2,187 Создание текстуры из файла, 35 Сообщения текстовые, для отладки, 310-314 утверждения времени компиляции, 309-310 утверждения, предупреждения и комментарии, 301-308 Спектр Филлипса, 267-269 Спецификации института IEEE, 323 Спутниковая ландшафтная информация, 124 Статические переменные, 55 Структура типа «дерево» и иерархия модели, 43—46 Суррогаты растительности, 255-257 Сущность, 86 Сцена cModelResource, 254 cSceneModel, 254 организация, 106- 108 разбиение пространства, 104 секторы, 107-108 Си также Квадрадеревья узлы и объекты, 88, 109-110 Сцены, внеинтерьерные z-буфер и базовый цвет, 236-239 анимация воды, 265-275 аппроксимация наружного освещения, 245-253 билборды, 255-257 водная мозаика, 264-265 изображение маски, 243-244 многоэтапный подход, 233-235 отображение на ландшафт, 260-261 отображение неровностей, 239-245 приемы рендеринга травы,257-259 рассеянный свет, 235-239 суррогаты растительности, 255-257 текстура скальной породы, 198-199, 212 текстура снежного покрова, 212 текстура травы, 198-199,212 текстурагрунта, 198-199, 212 формирование островов, 262-264 Счетчик отметок реального времени (RTSC), 295 т Таймер для хронометража кода, 314-320 области видимости, 320 Текстовые строки, 288-290 Текстура грунта, 198-199, 212 скальной породы, 198-199,212 сиежиого покрова, 212 Текстуры D3DXCreateTextureFromFile, 35 D3DXFillTextureTX, 60 IDirect3DTexture9, 35 базовые объекты, 35 высота и наклон поверхности, 202-203 генерация при помощи программы Т2. 195-196, 347 загрузка, 36 импорт, 35
Предметный указатель 359 интерполяция поверхностей, 201-202 качество. 194 компоновка буфера кадра, 212-215 методы построения, 194-197 настройки шаблона, 343 оборачивание в сравнении с ограничением адресов, 197 объект clmage, 207 объект cTexture, 81 пиксельный шейдер, 198 процедурные, шейдеры, 60 светорассеяние на объективе, 229 смешение, 197-211, 242-244 файлы эффектов, 38 шаблоны, 57-59 шум в естественной среде, 211 Тернер, Брайан (Turner, Brian), «Real-Time Dynamic Level of Detail Terrain Rendering with ROAM», 157 Тессендорф, Джерри (Tessendorf, Jerry), 266, 267 Тики таймера обновления в игре и, 69-70 подсчет, 315 счетчик, 273 Тип данных double, 53-55 float, 54-55 half, 53-55 int, 53-55 Типы данных HLSL, 53-56, 337-338 семантика, 55 Точки и векторы, 22-23 Точность вещественных чисел, 329-331 Трава приемы рендеринга, 257-259 текстура, 198-199, 212 Трассировка макрос для отладки, 313 стека, 303 Требования к процессору, 6, 348 Треугольники и ландшафтная геометрия. См. Метод ROAM У Уатт, Алан (Watt, Alan), 3D Computer Graphics, 29 Уведомление об ошибках, 302-306 Углы скалярное произведение векторов, 24-27 эйлеровы,32 Узел преобразования, 43 Узлы преобразований, 43 сцены, 88, 109-110 Ульрих, Тэтчер (Ulrich, Thatcher), 169 Управление временными приоритетами, 69-70 миром. См. Сцена приоритетом потоков, 69-70 Упрощающие меши, 36 Уровень моря, 262, 263 Условные выражения, 56, 338-339 Устройство ввода CD3DArcBall, 17, 18 Утверждения времени компиляции, 309-310 отладочные предупреждения и комментарии, 301-308 Утилиты, файлы D3DUtil, 18 Ф Файловый ввод-вывод, 88 Файлы D3DApp, 17 D3DEnumeration, 17 D3DFile, 17, 18 D3DFont, 17, 18 D3DSettings, 17, 18 D3DUtil, 18 DirectX Samples Framework, 17-18 dxreadme.htm, 16 FloatTools.h, 331 fxc.exe, 31,61 импорт текстур, 35
360 Предметный указатель (моделей) с расширением .х, 34 с расширением .bmp, 35 с расширением .dds, 35 с расширением .dib, 35 с расширением .jpg, 35 с расширением .png, 35 с расширением .tga, 35 с расширением ,х, 34 цифровых карт высоты (DEM, Digital Elevation Model), 124 (эффектов) с расширением .fx, 34 Файлы эффектов .fx-файлы, 34 cEffectFile, 82, 83, 222, 223 D3DXCreateEffectFromFile, 41 FindNextValidTechnique, 41 загрузка и поиск лучшего варианта реализации, 41-43 объект ID3DXEffect, 41 одно- и многопроходные варианты реализации в них, 38-40 однопроходные варианты в сравнении с многопроходными, 38-40 описание, 37 рендеринг воды, 276-280 рендеринг неба, 223-225, 226-227 функции на языке HLSL в них, 61-64 Флаги D3DXTRACKFLAG, 48 контроль состояния битов, 281-284, 309 Фоновое изображение и облака, 228-229 Фрактальное броуновское движение (fBM, Fractional Brownian Motion), 134-135 Функции на языке HLSL, внутри файлов эффектов, 61-64 список поддерживаемых, 339-343 уведомления в квадрадеревьях, 116 языка HLSL, встроенные, 56-57 Функция mul, 57 QueryPerformanceCounter, 315 TextOut, 20 вывода текста, 20 рефракции, 57 шума Перлина, 127-135 X Хиггинботэм, Вилли (Higginbotham, Willy), 5 Хоппе, Хьюз (Hoppe, Hughes), 36 Хоффман, Натаниэль (Hoffman, Nathaniel), 246, 250 Хронометраж кода, 314-320 ц Цвета computeSunLightColor, 247 RGBA и пиксельные шейдеры, 59 z-буфер и рассеянное освещение, 236-239 пиксельный шейдер, 59 смешение, 207 Целые числа, получение из вещественных, 327-329 ч Числовые выражения, 56,338-339 ш Шаблон класса с единственным экземпляром, 286 Шаблоны HLSL, 57-59 SetSamplerState, 57 Шейдеры вершинные и пиксельные, 43, 51, 52-53 пиксельные, 198 См. также Язык описания шейдеров высокого уровня (HLSL) Шум смешение текстур, 211 шаблоны н анимация облаков, 227 э Эйлеровы углы, 32 Элементы мозаики cTileTerrain и cTileTerrainSection, 190
Предметный указатель взаимосвязанная ландшафтная мозаика, 187-192 водная мозаика, 264-265 Я Язык Cg (NVIDIA), 51 Язык описания шейдеров высокого уровня (HLSL) D3DXCompileShader, 61 D3DXCreateEftect, 61 выражения и встроенные функции, 56-57 доступные типы данных, 337-338 настройки шаблона, 343 __________361- обзор, 43, 51 переменные и типы данных, 53-56 поддерживаемые функции, 339-343 работа на унаследованной технике, 60-61, 213, 242 текстуры и шаблоны, 57-59 файлы эффектов, 43, 61-64 формат вершинных и пиксельных шейдеров, 43,51,52-53 числовые и условные выражения, 338-339 шейдеры процедурных текстур, 60 См. также Пиксельные шейдеры, Вершинные шейдеры
Содержание Введение ....................................................5 Благодарности ..............................................12 Часть I. Основы трехмерной графики..........................13 Глава 1. DIRECTX 9.0 и D3DX: первые шаги....................15 Настройка Visual Studio.NET...............................15 Приложения Direct3D Sample Framework......................17 Математическая библиотека D3DX............................19 Система координат Direct3D................................20 Точки и векторы в D3DX....................................22 Нормирование векторов.....................................23 Скалярное произведение....................................24 Векторное произведение....................................27 Матрицы в D3DX............................................28 Вращение кватернионов.....................................31 Литература................................................33 Глава 2. Основные ЗЭ-объекты ...............................34 Основные объекты Direct3D.................................34 Загрузка и отображение модели в D3DX......................36 Применение файлов эффектов Direct3D ......................37 Контейнеры кадров и мешей в D3DX..........................43 Скелетная анимация и отекстуренные меши...................46 Литература ...............................................50
Содержание 363 Глава 3. Язык описания шейдеров hlsl..........................51 Формат шейдера в HLSL.......................................52 Переменные и типы данных....................................53 Встроенные функции и выражения языка........................56 Работа с текстурами и сэмплерами ...........................57 Шейдеры процедурных текстур.................................60 Работа на унаследованных системах...........................60 HLSL-функции в файлах эффектов..............................61 Глава 4. Обзор движка Gaia ................................. 65 Встречайте, Gaia - движок генерации ландшафтов..............65 Главное приложение..........................................67 Создание пулов данных ......................................70 Управление разделяемыми ресурсами данных....................76 Базовый класс ресурсов......................................79 Ресурсы-текстуры и материалы поверхностей ..................81 Ресурсы методов рендеринга..................................82 Буферы индексов и вершин....................................83 Ресурсы модели..............................................85 Узлы и объекты сцены........................................88 Очередь на рендеринг........................................89 Редактор модели............................................102 Литература ................................................102 Часть II. Введение в системы ландшафтного синтеза............103 Глава 5. Управление миром ...................................105 Что стоит за организацией сцены ...........................106 Базовый вариант квадрадерева ..............................109 Расширение квадрадеревьев..................................111 Введение дополнительного измерения квадрадеревьев .........114
364 Содержание Быстрый поиск в квадрадеревъях............................115 Медленный поиск в квадрадеревъях......................... 117 Литература .............................................. 122 Глава 6. Основы ландшафтной геометрии ......................123 Карты высот как входные данные о ландшафте ...............123 Процедурные карты высот...................................125 Смещение средней точки....................................126 Шум Перлина ..............................................127 Обработка данных высотных карт.......................... 135 Базовые классы ландшафтной геометрии......................139 Индексные буферы в ландшафтной геометрии .................140 Вершинные буферы в ландшафтной геометрии..................142 Рендеринг участков ландшафта .............................148 Демонстрация базового ландшафта ..........................152 Литература................................................153 Глава 7. Ландшафтная система ROAM...........................154 Меши с оптимальной подгонкой в реальном времени ..........155 Решение о разбиении.......................................157 Реализация ROAM...........................................160 Создание экранной геометрии в ROAM........................165 Литература ...............................................168 Глава 8. Методы мозаичной геометрии ........................169 Блочный ландшафт .........................................170 Управление блоками геометрии..............................172 Мозаичное представление блоков ландшафта ..................179 Взаимосвязанная ландшафтная мозаика.......................187 К вопросу о трещинах при смене уровней LOD................192 Литература ...............................................193
Содержание 365 Глава 9. Методы текстуризации ...............................194 Болъшой-болъшой расплывчатый мир ..........................194 Создание наложения текстур поверхности ....................197 Природа и шум..............................................211 Компоновка буфера кадра....................................212 Глава 10. Страна высокого неба ..............................218 «Небесная оболочка»........................................218 Небесный купол.............................................225 Анимация облаков...........................................227 Рассеяние света на объективе ...............................229 Глава И. Рендеринг сцен на открытом пространстве.............233 Многоэтапный подход ........................................233 Рассеянный свет............................................235 Отображение неровностей ....................................239 Аппроксимация наружного освещения..........................245 Объединение результатов....................................253 Литература .................................................253 Глава 12. ЗО-садовник..............*........................ 254 Суррогаты растительности...................................255 Трава - «меховой покров» матери-природы ....................257 Золотых колосьев волны ..................................... 260 Глава 13. Океанские воды ....................................262 Остров посреди моря........................................ 263 Водная мозаика.............................................264 Анимация водной поверхности................................265 Рендеринг воды .............................................275 Конец пути ................................................ 280 Литература .................................................280
366 Содержание Служебные классы Gaia .....................................281 Контроль битовых флагов..................................281 Класс с единственным экземпляром.........................285 Строки...................................................288 Данные о системе.........................................291 Утверждения, предупреждения и комментарии ...............301 Утверждения времени компиляции...........................309 Текстовые сообщения при отладке..........................310 Хронометраж кода.........................................314 Литература ..............................................321 Секреты плавающей запятой .................................322 Организация вещественных данных..........................323 Знаковый разряд .........................................326 Преобразование вещественных чисел в целые................32 7 Ограничение точности вещественных чисел..................329 Ограничение диапазона значений вещественных чисел .......331 Вещественные степени числа 2.............................334 Руководство программиста...................................335 Коды идентификации процессоров Intel.....................335 ~ Direct3DHLSL: типы данных ...............................337 Direct3D HLSL: выражения.................................338 Direct3D HLSL: встроенные функции .......................339 Direct3D HLSL: настройки шаблона.........................343 Рекомендуемая литература ..................................345 Математика...............................................345 Программирование ЗИ-задач ...............................345 Научные исследования ....................................346 Другие полезные Web-сайты ...............................346 Программы и утилиты......................................346
Содержание __367 Обзор компакт-диска.........................................347 Описание папок............................................347 Системные требования......................................348 Программные требования....................................348 Установка ................................................348 Обновления и исправления опечаток.........................348 Предметный указатель .......................................349
категория > прейраммкромние игр К книге прилагается компакт диск! На нем вы найдете все файлы, необходимые для завершения создания движка, плюс примеры графики, 30-моделей и Microsoft® DirectX" 9 SDK Системные требования: Операционные системы: Windows 2ООО/ХР; процессор: Pentium III 1 ГГц или выше; 128 Мб (рекоменд. 256 Мб) ОЗУ; 500 Мб на жестком диске; GeForce 3, Radeon 8500 и выше; Web-браузер, Интернет; CD-ROM; мышь; компилятор Microsoft Visual Studio.NET. ПРИГЛАШАЕМ: - авторов книг по компьютерной тематике; - дистрибьюторов книжной продукции. КУДИЦ-ОБРАЗ Тел./факс: (095) 333-82-11, 333-65-67 Интернет-представительство и магазин: http://books.kudits.ru ул. Профсоюзная, д. 84/32, подъезд б, эт.