Должен сказать, что статья эта планировалась очень давно – года так с 2004, когда был написан трёхмерный движок, использующий описываемый метод. Однако, обилие формул и лень заставили меня уже в скором времени забросить написание статьи. Последний раз я касался текста в 2008 году. На этой дате статья и остановилась, и возрождать я её не планировал. Но недавно, в теме про исходники Quake 2, меня попросили всё же рассказать о применённой в моём трёхмерном движке технологии построения картинки. Актуальность столь запоздавших статей для современного программирования трёхмерной графики, конечно, нулевая, но возможно, начинающим изучать OpenGL эта статья будет интересна.
Для начала я покажу, какие именно получаются изображения при применении описываемого метода. Вот скриншоты моего движка 2004 года:
Скриншоты трёхмерного движка.
Как видите, для простейшего движка получаются достаточно красивые картинки.
Идея алгоритма заключается в том, что многоугольник, источник тени (в дальнейшем – МИТ), своей тенью разбивает многоугольник, приёмник тени (в дальнейшем – МПТ) на фрагменты A, B, C, D, E, как показано на рисунке ниже. Можно заметить, что для фрагментов A, B, E, D источник света включён, а для фрагмента C источник света отключён
Разбиение на фрагменты тенью.
Общий алгоритм построения освещения таков:
- Выбираем источник света.
- Каждый многоугольник сцены разбиваем на фрагменты многоугольником, который может отбрасывать тень. При этом вновь получившиеся фрагменты будут являться новыми многоугольниками сцены, на которые нужно будет вновь построить тени от других многоугольников (но которые сами не будут источниками теней – бессмысленно использовать для построения тени части вместо исходного целого многоугольника). Такую операцию необходимо провести для всех многоугольников сцены.
- Переходим на шаг 1 для следующего источника света.
В результате такого разбиения мы получим для каждого исходного многоугольника набор фрагментов, для каждого из которых известно, какие источники света его освещают. На практике это означает, что при выводе этих фрагментов движок должен включать или отключать источники света, для которых точно известно, что они данный фрагмент не освещают.
Поскольку мы используем только восемь источников света OpenGL, то при разбиении сцены можно просто выбрать только те источники из общего списка источников света, которые максимально сказываются на выбранном многоугольнике сцены. В самом простом случае это можно сделать, например, посчитав расстояние от источника до вершин многоугольника и выбрать источники с минимальными расстояниями до вершин. Конечно, в этом случае часть источников могут оказаться бесполезными для выбранного многоугольника (источник может вообще быть полностью загорожен другим многоугольником), но зато алгоритм выбора очень прост.
Как видите, метод несложный, однако, потребует алгоритма построения фрагментов, зная МИТ, МПТ и положение источника света.
Реализация алгоритма
Условимся считать все многоугольники выпуклыми. Есть это не так, их следует разбить на выпуклые части. Для реализации алгоритма нахождения фрагментов нам понадобятся некоторые функции, а именно:
Функция, определяющая позицию точки относительно некоторой плоскости.
Входные данные:
— Точка плоскости ;
— Вектор нормали к плоскости ;
— Точка, для которой определяется положение относительно плоскости .
Возвращаемое значение: 1 — точка находится выше плоскости, -1 — точка находится ниже плоскости, 0- точка находится в плоскости.
Для реализации этой функции воспользуемся уравнением плоскости, тогда (1).
Полученное определяет положение точки относительно плоскости.
Функция должна возвращать -1, если , возвращать 1, если и возвращать 0, если .
Однако, мы работаем на разрядной сетке ЭВМ, поэтому сравнивать с нулём нельзя. Вместо этого мы будем здесь и далее сравнивать с некоторым малым , значение которого проще всего подобрать опытным путём так, чтобы алгоритм не давал сбоев.
В этом случае функция должна возвращать -1, если , возвращать 1, если и возвращать 0 во всех остальных случаях.
Функция, определяющая точку пересечения прямой с плоскостью.
Входные данные:
— Точка плоскости ;
— Вектор нормали к плоскости ;
— Первая точка прямой ;
— Вторая точка прямой ;
Возвращаемое значение: координаты точки пересечения либо флаг, показывающий что пересечения нет.
Вектор , задающий направление прямой вычисляется как
Тогда скалярное произведение между вектором нормали и вектором прямой будет .
Если (в нашем случае это условие примет вид и ), то прямая и плоскость пересекаться не могут, поскольку вектора перпендикулярны.
Если же пересечение возможно, то координаты точки пересечения могут быть получены по формулам где — значение, полученное по формуле (1).
Функция, определяющая точку пересечения луча с плоскостью.
Входные данные:
— Точка плоскости ;
— Вектор нормали к плоскости ;
— Первая точка луча (начало луча) ;
— Вторая точка луча .
Возвращаемое значение: координаты точки пересечения либо флаг, показывающий что пересечения нет.
Задача сводится к предыдущей, но появляются дополнительные условия возможности пересечения, поскольку луч, в отличие от прямой, полубесконечен.
Так, если получится, что для или для (в нашем случае для или для ), то луч плоскость не пересекает.
Функция, определяющая, нахождение точки внутри выпуклого многоугольника, лежащего в плоскости.
Входные данные:
— Точки многоугольника, лежащие в одной плоскости ;
— Точка, находящаяся в плоскости многоугольника, для которой проверяется её нахождение внутри многоугольника .
Возвращаемое значение: флаг, показывающий находится точка внутри многоугольника или нет.
Алгоритм работы функции выглядит так:
Введём переменную =индекс последней точки многоугольника;
Цикл по от 0 до ;
Введём переменные индексов трёх последовательных точек ,,;
Если , то ;
Если , то ;
Возьмём три последовательные точки многоугольника ,,;
Создадим три вектора: по формулам
Вычислим векторные произведения и ;
Вычислим скалярное произведение ;
Если (в нашем случае ), то точка внутрь многоугольника не попадает.
Конец цикла;
Точка попадает внутрь многоугольника.
Идея работы функции заключается в том, что три подряд идущие точки многоугольника задают два ребра многоугольника, преобразовав которые в вектора, можно вычислить векторное произведение векторов первого ребра со вторым и первого ребра с вектором, составленным из первой точки многоугольника и исследуемой точкой.
При этом, направления получившихся векторов зависят от того, по какую сторону от первого ребра находится точка. Для вычисления согласованности получившихся векторов используется их скалярное произведение, которое будет отрицательным, если вектора направлены в противоположные стороны.
Алгоритм построения фрагментов
Входные данные:
— Точки выпуклого многоугольника, являющегося источником тени), лежащие в одной плоскости;
— Точки выпуклого многоугольника, на который отбрасывается тень), лежащие в одной плоскости;
-Точка положения источника света (далее по тексту ИС — «источник света»);
Возвращаемые данные: флаг, показывающий существует тень или нет, и массив многоугольников, на которые разбивается МПТ тенью.
Получение точек тени в плоскости МПТ
Рассмотрим алгоритм получения точек тени в системе координат МПТ.
- Проверим, что МИТ и МПТ имеют не менее трёх точек, иначе это, строго говоря, вообще не многоугольники, и тени нет.
- Проверим, может ли ИС освещать МПТ. Условимся, что если ИС лежит выше плоскости МПТ (нормаль МПТ направлена в сторону ИС), то он может освещать МПТ, иначе, это невозможно. Если ИС освещать МПТ не может, то тени нет.
- Поскольку МИТ может пересекать МПТ, то нужно скорректировать МИТ так, чтобы он всегда был выше МПТ. Для этого нужно пройтись циклом по рёбрам МИТ, оставляя точки, которые лежат выше плоскости МПТ и отбрасывая точки, лежащие ниже плоскости МПТ, причём, если точки какого-либо ребра находятся по разные стороны от плоскости, задаваемой МПТ, то нужно добавить к новому МИТ эти точки пересечения. Таким образом, у нас будет новый МИТ, лежащий точно выше МПТ. Если после коррекции точек МИТ меньше трёх, то тени нет.
- Возможна ситуация, когда МПТ и ИС находятся по одну сторону от МИТ. В этом случае тоже тени нет. Это можно проверить определив положение ИС относительно плоскости МИТ и сравнивая с этим положением положения точек МПТ относительно плоскости МИТ. Если хоть одна точка МПТ лежит по другую сторону плоскости МИТ, нежели ИС, то тень существует, иначе тени нет.
- Теперь нужно сформировать теневой объём. Всё, что попадает в теневой объём, ИС не освещается. Фигура, задающая теневой объём, ограничивается сверху МИТ, снизу её ничто не ограничивает (так как объём простирается в бесконечность), а по бокам её ограничивают четырёхугольные грани, у которых две вершины так же находятся в бесконечности, а две другие совпадают с вершинами ребра МИТ.
- Поскольку МИТ уже задан, то требуется найти четырёхугольные грани. Так как нам от этих граней нужно сейчас только то, что они задают плоскости, то заменим бесконечные вершины на конечные. Для этого возьмём рёбра МИТ, и пустим луч от ИС через вершины рёбер. Для примера возьмём первые две точки МИТ и , тогда координаты точек грани теневого объёма определяются как где
Поскольку МИТ мог быть ориентирован как угодно, то нормали граней теневого объёма могут быть направлены как внутрь объёма, так и наружу. Условимся, что нормали теневого объёма должны быть направлены наружу.
Для этого сравним положение точки, точно попадающей в теневой объём относительно плоскостей, задаваемых гранями теневого объёма. Такая точка может быть получена, если найти геометрический центр МИТ
и пустить луч от ИС через этот центр, так же, как это делалось для нахождения вершин граней теневого объёма. Если при определении положения этой точки относительно плоскостей граней будет получено для какой-либо грани, что точка находится выше выбранной грани, то грань следует “перевернуть”, т.е. поменять знаки координат вектора нормали грани на противоположные. Направление обхода самих точек, задающих грань можно не менять, так как это в дальнейшем не понадобится.
- Теперь мы должны найти точки тени. Сначала мы найдём точки МПТ, лежащие внутри теневого объёма. Делается это так: каждую точку МПТ нужно сравнить со всеми плоскостями, задаваемыми гранями теневого объёма, и если окажется, что для всех граней она находится ниже плоскости грани (или в грани), то такая точка точно лежит внутри теневого объёма. После этого надо найти точки пересечения теневого объёма с МПТ. Для этого мы для каждой грани теневого объёма проходим по всем рёбрам МПТ и находим пересечения ребёр с плоскостью данной конкретной грани, проверяя, попадает ли точка пересечения внутрь теневого объёма. Однако, это ещё не все точки из которых может собираться тень. Нужны ещё точки пересечения граней теневого объёма с МПТ. Для того чтобы их найти, нам нужно вычислить точки пересечения плоскости МПТ и лучей, выходящих из ИС и проходящих через вершины МИТ, проверив, что все полученные точки лежат внутри МПТ. В конечном итоге, у нас должен получиться массив точек, из которых собирается тень на плоскости МПТ. Однако эти точки ещё не упорядочены.
- Дальше точки тени следует перевести в систему координат МПТ (будет плоскость XZ, где координата Y=0), то есть спроецировать на плоскость МПТ. Чтобы отсортировать точки тени, можно взять центр этих точек (считается как среднее арифметическое координат в плоскости) и отсортировать по углу (считается через арктангенс (atan2) координат) и расстоянию от центра.
- Теперь точки тени у нас упорядочены, пересчитаны в плоскость МПТ и пора строить фрагменты. Совершенно естественно, что первым фрагментом будет сама тень (если, конечно, она не стянулась в линию (с допуском по погрешности), что тоже возможно — в этом случае считаем, что тени нет). Теперь берём каждую сторону многоугольника тени и добавляем в фрагмент все те точки, которые находятся снаружи многоугольника тени (лежат с центральной точкой многоугольника тени по разные стороны от выбранной стороны многоугольника тени), как показано на рисунке. Кроме того, следует добавить и точки пересечения сторон МПТ и прямой, содержащей выбранную сторону многоугольника тени. Разумеется, одинаковые до погрешности по координатам точки добавлять не следует. После добавления всех точек во фрагмент, обрезаем МПТ по краям и повторяем операции со следующей стороной многоугольника тени.
Создание фрагмента
- Получив все фрагменты, пересчитываем их координаты обратно в трёхмерную систему координат, не связанную с МПТ.
Вот, собственно, и весь алгоритм. Все пункты с 1 по 10 можно посмотреть в файле shadow.cpp, находящегося в папке с демонстрационной программой Lighting.
В архиве по ссылке (опять не на github — я с ним пока ещё не подружился) присутствуют программы с исходниками:
- Демонстрационная программа Lighting, строящая в реальном времени тени от двух источников света на пирамидку и плоскость под ней.
- 3D-движок, использующий вышеописанную технологию.
- Редактор карт 3D-движка (целиком на Win32API, никакого OVL или MFC).
Комментарии (27)
Idot
17.05.2017 11:18PS добавь к статье хаб https://habrahabr.ru/hub/3d_graphics/
da-nie
17.05.2017 11:24Хм. В списке хабов не могу найти ничего похожего на «работа с 3D графикой». Может быть, я где-то не там ищу?
Idot
17.05.2017 11:39А этот хаб https://habrahabr.ru/hub/cgi/ находится?
da-nie
17.05.2017 12:09Сами ссылки открываются. Но там, где «Хабы» таких хабов как «CGI» или «работа с 3D графикой» у меня нет. Но у меня поток задан как «разработка».
DrZlodberg
17.05.2017 11:39По логике очень напоминает псевдо-тени в движках типа DOOM 1,2. Хотя там больше 1 активного источника на сектор было сделать нельзя.
da-nie
17.05.2017 12:14В Doom можно было задать освещённость сектора и из таких секторов как раз и делали тени. Здесь же тени предвычисляются автоматически заранее, а смешивание цветов выполняет уже OpenGL. Кстати, отсюда присутствуют и те же ограничения на источники света — нельзя на большой плоскости (заданной по 4 координатам) поставить в центре на небольшой высоте над плоскостью источник света и получить пятно рядом с ним (так как свет будет посчитан в 4 точках, задающих плоскость). Боле того, в этом случае пр небольшой высоте источника над плоскостью плоскость вообще может стать тёмной. Здесь исходные многоугольники должны быть сравнимы с расстоянием до источника света.
DrZlodberg
17.05.2017 13:40Ну тут по сути те же сектора (полигоны ведь режутся на куски) и источники для каждого. Так что очень похоже.
Разница только в большей гибкости, Кстати динамические объекты всё равно этой тени не заметят, как я понимаю. На них будут действовать все источники в пределах досягаемости, что палевно.da-nie
17.05.2017 14:35Ну в этом смысле да, похоже.
Динамические объекты можно освещать так же, как сейчас освещается рука с палочкой в этом движке (освещается только от фрагмента, на котором стоит игрок). Тень не будет на них падать, но освещение объекта будет меняться, когда объект будет перемещаться.
Idot
Ну, почему же нулевая? Для моего http://www.moddb.com/mods/wolfgl-3d — будет в самый раз, там как раз освещение работает через GL_LIGHTING :)
da-nie
Кстати, у меня ваш Wolf почему-то вылетает с ошибкой «Программа выполнила недопустимую операцию» и совсем не запускается. У меня, правда, Windows XP (да, я ретроград :) ), но компьютер трёхгодичной давности.
И, кстати, попробуйте исходники прогнать статическим анализатором, например, CPPCheck 1.64 (1.7… чего-то там у меня глючила и врала) — у вас есть критические ошибки. Возможно, это позволит улучшить стабильность работы программы.
Idot
У меня предыдущая версия была тоже XP :)
У Вас в папке с игрой файлы GAMEMAPS.WL6 и остальные .WL6 (или .SOD) от оригинальной игры версии 1.4 присутствуют?
(помню, что у кого-то игра вылетала просто из-за отсутствия этих файлов)
da-nie
А, вот оно что! :) Скопировал от оригинального. Запустилось. Но графика перепуталась почему-то. А в самой игре получилось вот что:
:D
Idot
А версия файлов у Вас какая? Поддерживается 1.4
PS фонарик можно выключать.
da-nie
Не знал, что Wolf-3D имел версии. :) У меня «Release date: 16th Jun 1992» и никакого номера версии я не нашёл. Он у меня под DOS который. Вот тут я в него играю на 4 минуте: ссылка
Idot
Вот тут http://ftp.3drealms.com/faq/w3dfaq420.txt написано, что можно официально взять бесплатно отсюда ftp://ftp.gamers.org/pub/games/wolf3d/official/
Idot
PS официально, потому что эта ссылка дана на сайте 3D-Realms (вряд ли бы 3D-Realms давала пиратскую ссылку)
da-nie
Ага. :) Заработало. Но фонарик странный.
А какие у вас настройки параметров фонарика и материала потолка?
Я имею в виду вот это:
И вот это:
Очень похоже на то, что фонарик работает на GL_SPECULAR, а не за счёт GL_DIFFUSE.
Idot
Фонарик имеет два конуса:
Материал потолка ещё не задавал (насколько я помню), то есть должен быть стандартный по умолчанию.
da-nie
Тут стоит правильно настроить GL_SHININESS, чтобы Specular почти не влиял, потому что блик на крупных полигонах способен сильно испортить картинку. И возможно стоит настроить коэффициенты ослабления света — я уже не помню, как они называются в OpenGL. От них тоже картинка зависит сильно.
Idot
Спасибо за дельный совет! Фонарик, действительно, очень странно освещает.
Idot
PS в принципе у меня есть редактор материалов, работающий с файлами настроек вида
но, пока он к Вольфу ещё не прикручен.
da-nie
Вообще, фонарик надо шейдерами делать. Но я их совсем не знаю. А в инете вроде как есть примеры.
Кстати, есть ещё один простой способ построения красивых теней. Но он медленный и заключается фактически в обратной трассировке луча. Можно дважды строить сцену — первый раз без освещения (с полной яркостью) и считать её в буфер. А второй раз построить в условных цветах (цвет равен номеру полигона) и опять считать в другой буфер вместе с глубиной пикселя. А дальше пробежаться по буферу и для каждой точки картинки посмотреть, какие источники её освещают и вычислить освещённость точки (0-1 по каналам R,G,B), после чего умножить её значение из буфера на эту освещённость. Собственно, картинки с геометрией из этой статьи таким способом и были сделаны.
Idot
Шейдеры будут позже, а сейчас мне интересно поработать с со стандартным GL_LIGHTING
da-nie
Кстати, как вы отсекаете невидимые грани? В оригинальном Wolf-3D была трассировка лучей при сканировании по экрану, но вряд ли она применима в вашем варианте с OpenGL и произвольной ориентацией головы.
Idot
То какую грань из четырёх рисовать у Кармака определяется координатами игрока. Например, если игрок находится левее стены, то рисуется именно левая грань "квадрата", и не рисуется правая. Трассировка же определяет какие именно "квадраты" рисовать.
Моя же версия сейчас находится в оптимизации. Для начала попробовал уменьшить число лучей — выпуская их только для углов и центра "квадрата", но, полезли глюки. Так же пытаюсь использовать Quad-Trees, и тоже в процессе работы.
PS после смены версии C++ проект перестал компилироваться. Сейчас уже компилируется, но, всё ещё не запускается. Как начнёт запускаться — продолжу оптимизацию и доделывание.
da-nie
Правильно я понял, что вы просто пускаете множество лучей и рисуете те грани, с которыми они пересеклись? Такой способ может давать ошибки, если луч просто не попадёт на большом расстоянии, скажем, в проём между блоками. Дерево не будет отсекать невидимые блоки без списка видимых для узла, хотя и сократит число рассматриваемых блоков и упорядочит их. У вас самый простой вариант был бы метод порталов, а учитывая плоскую карту, он тут вообще довольно прост.
Idot
Нет, я сейчас делаю ровно наоборот посылаю луч от "квадрата" к наблюдателю, и смотрю не пересёкся ли он. И поскольку, как Вы заметили, луч может и не попасть в проём между блоками, то наблюдаются глюки.