- Подписка на печатную версию:
- Подписка на электронную версию:
- Подшивки старых номеров журнала (печатные версии)
LXF113-114:Игрострой
Материал из Linuxformat.
- GLSL Проникаем в тайны видеоускорителя с программами на шейдерах.
Игрострой: Ни строчки кода! |
---|
Игрострой: Шейдеры |
---|
|
Да будет свет!
- ЧАСТЬ 3 Земля вращается вокруг Солнца – ну, так уж получилось. А это значит, что все объекты реального мира мы привыкли видеть в лучах нашего светила. Настало время принести свет в мир виртуальный! Андрей Прахов открывает учебник оптики...
На прошлых уроках мы познакомились с трудным, но интересным миром шейдеров, научились создавать простейшие программы на GLSL и даже построили виртуальный дом. Однако признайтесь, приложив руку к сердцу, что все сделанное больше напоминает самопальные абстракции и никак не тянет на заявленную реалистичность. Если вы когда-нибудь работали с каким-либо трехмерным редактором, то слышали утверждение, что от света зависит многое. А свет бывает самый разный, и в этом нам сегодня предстоит убедиться.
Игра со светом
В LXF111 мы рассмотрели диффузную модель освещения и привели код ее простейшей реализации. Вот только конечный результат был прямо пропорциональным затраченным усилиям, то есть, честно говоря, никудышным. Чтобы понять, почему диффузная модель не справляется с просчетом реалистичности освещения, вспомним некоторые основы физики, изучаемой еще в школе. Закон отражения света гласит, что угол, под которым луч отражается от поверхности, равен углу падения. На практике, однако, это наблюдается не всегда: в окружающем нас мире не так уж много по-настоящему гладких объектов, и свет, отражаясь от хаотически расположенных граней различных неровностей, рассеивается во все стороны (никогда не задумывались над вопросом, почему лед прозрачный, а снег – нет? Именно поэтому: в снегу свет многократно рассеивается на границах кристалликов). Разумеется, моделировать процесс хаотического рассеяния света на поверхности, скажем, стола было бы довольно трудоемко, поэтому в виртуальном мире просто выделяют два типа отражения: диффузное (рассеянное) и зеркальное. Например, возьмем два различных материала – пластик и хромированную сталь. В первом случае блик получается тусклым и невыразительным, что обусловлено особенностями строения вещества, зато металл обладает ярким и резким отражением. Соответственно, зеркальный шейдер должен иметь параметр мощности отражения. Кроме него, для бликов можно использовать уникальный цвет, независимый от основного. Это позволит достичь некоторых интересных визуальных эффектов.
Чтобы не изобретать велосипед, воспользуемся кодом диффузного шейдера из прошлого урока. Также договоримся, что все основные вычисления освещения и конечного цвета будут производится в вершинном шейдере. Вообще-то правильнее было бы рассчитывать окончательный цвет во фрагментном шейдере, а в вершинном вычислять только его интенсивность. Но это затруднит восприятие основной идеи и изрядно загромоздит код.
Рис. 1. Наши шейдеры: диффузный (а) и зеркальный (б).
Взгляните внимательнее на известный нам диффузный шейдер:
varying float outLight; void main() { vec3 ll=gl_LightSource[0].position; vec3 position=vec3 (gl_ModelViewMatrix * gl_Vertex); vec3 LightVec = normalize (vec3 (ll)-position); vec3 norm = normalize (gl_NormalMatrix * gl_Normal); outLight = max (dot (norm, LightVec), 0.0); gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
Конечным результатом здесь является вычисленное значение интенсивности освещения. В нашем случае переменная varying должна содержать готовый цвет для конкретной вершины:
varying vec4 Color;
Для работы шейдера необходимо объявить основной и отражающий цвета объекта, а также интенсивность зеркального отражения. Сделать это можно разными путями – использовать uniform-переменные или константы. Воспользуемся для простоты вторым вариантом:
const vec4 Diffuse = vec4 (1.0, 0.0, 0.0, 1.0); const vec4 Specular = vec4 (1.0, 1.0, 1.0, 1.0); const float SpecPower = 60;
Диффузный цвет вычисляем знакомым способом:
vec3 ll=gl_LightSource[0].position; vec3 Position=vec3 (gl_ModelViewMatrix * gl_Vertex); vec3 LightVec = normalize (vec3 (ll)-Position); vec3 Norm = normalize (gl_NormalMatrix * gl_Normal); vec4 Diff = Diffuse * (max (dot (Norm, LightVec), 0.0));
Начальная строка здесь заносит в переменную ll координаты первого источника света. Затем находятся координаты вершины в пространстве обзора. Для вычисления диффузного рассеивания нужно определить нормаль между поверхностью и лучом света, что и делают две следующие строки. А вот последняя строка немного видоизменена по сравнению с начальным кодом. Переменная Diff содержит конечный результат диффузного освещения, который получается путем перемножения основного цвета на интенсивность освещения (более подробно о диффузном шейдере говорилось в LXF107).
Для вычисления зеркального отражения нам понадобится вектор, определяющий направление обзора, и вектор отражения от поверхности объекта. В первом случае, так как по умолчанию точка просмотра совпадает с началом координат (0, 0, 0) в пространстве координат обзора, нужно всего лишь инвертировать и нормализовать полученные ранее координаты вершины:
vec3 ViewVec = normalize (-Position);
Вектор отражения легко найти, если воспользоваться встроенной функцией reflect, которая имеет два параметра: вектор освещения (направление от источника к поверхности) и нормаль поверхности. Так как вычисленный ранее вектор LightVec имеет обратное направление (от поверхности к источнику света), то для функции reflect его также нужно просто инвертировать:
vec3 ReflectVec = reflect (-LightVec, Norm);
Для окончательного расчета блика воспользуемся формулой, взятой из модели освещения Блинна:
Spec = max(0, (ReflectVec, ViewVec))SpecPower
Как и в случае с диффузией, полученное значение перемножается с цветом Specular. Осталось только сложить имеющиеся цвета и передать на обработку фрагментному шейдеру. Конечный код вершинного и фрагментного шейдеров выглядит так:
//GLSL vertex shader varying vec4 Color; void main() { const vec4 Diffuse = vec4 (1.0, 0.0, 0.0, 1.0); const vec4 Specular = vec4 (1.0, 1.0, 1.0, 1.0); const float SpecPower = 60; vec3 ll=gl_LightSource[0].position; vec3 Position=vec3 (gl_ModelViewMatrix * gl_Vertex); vec3 LightVec = normalize (vec3 (ll)-Position); vec3 Norm = normalize (gl_NormalMatrix * gl_Normal); vec4 Diff = Diffuse * (max (dot (Norm, LightVec), 0.0)); vec3 ViewVec = normalize (-Position); vec3 ReflectVec = reflect (-LightVec, Norm); vec4 Spec = Specular * (pow (max (dot (ReflectVec, ViewVec), 0.0),SpecPower)); Color = Diff+Spec; gl_Position = ftransform(); } //GLSL fragment shader varying vec4 Color; void main() { gl_FragColor = Color; }
Рассмотренная модель освещения Блинна является стандартом де-факто в мире трехмерной графики. Есть гораздо более сложные и необычные по визуальному эффекту алгоритмы. Давайте познакомимся с одним из них, который разработала Эми Гуч. Суть заключается в том, что при нахождении источника освещения позади объекта создается впечатление, что свет проходит сквозь него, попутно изменяя окраску. При всей зрелищности эффекта, написать его не составляет труда.
За основу возьмем код, созданный для модели Блинна. Однако на этот раз вершинный шейдер будет производить только вычисления для освещения, а фрагментный – непосредственно реализовывать эффект. В связи с этими новыми условиями основная часть вершинного шейдера будет выглядеть так:
varying float NdotL; varying float Spec; void main() { vec3 ll=gl_LightSource[0].position; vec3 Position=vec3 (gl_ModelViewMatrix * gl_Vertex); vec3 LightVec = normalize (vec3 (ll)-Position); vec3 Norm = normalize (gl_NormalMatrix * gl_Normal); vec3 ViewVec = normalize (-Position); vec3 ReflectVec = reflect (-LightVec, Norm); Spec=pow (max (dot (ReflectVec,ViewVec), 0.0), 60); ....... gl_Position = ftransform (); }
Новая varying-переменная Spec хранит результат расчета зеркального отражения для конкретной точки. Сама реализация отражения тоже претерпела некоторые изменения. Для упрощения чтения кода убран цвет отражения, а интенсивность, по умолчанию, равна 60.
Следующая неизвестная переменная NdotL передает во фрагментный шейдер результат проверки нахождения источника света позади объекта по выражению (LightVec, Norm) < 0:
NdotL = (dot(LightVec, Norm) +1.0) * 0.5;
На этом работа вершинного шейдера завершена. Для окончательного расчета эффекта введем новые переменные:
- Color1 – цвет, принимаемый объектом при нахождении источника освещения перед ним;
- Color2 – то же, при нахождении источника освещения позади него.
Вычисление конечного цвета вершины производится с помощью функции смешения mix (LXF109):
vec3 ColorAll=mix(Color2, Color1, NdotL);
Осталось только добавить к результату имеющийся расчет освещения:
gl_FragColor = vec4 (ColorAll + Spec, 1.0);
Окончательный код фрагментного шейдера будет выглядеть так:
//GLSL fragment shader varying float NdotL; varying float Spec; void main() { const vec3 Color1=vec3 (0.5, 1.0, 0.0); const vec3 Color2=vec3 (0.0, 0.0, 1.0); vec3 ColorAll=mix (Color2, Color1, NdotL); gl_FragColor = vec4 (ColorAll + Spec, 1.0); }
Тайны зазеркалья
В свое время, играя в NFS (Need for Speed), я выжимал из компьютерного «железа» все соки лишь только для того, чтобы любоваться во время гонки красивым зеркальным отражением на машинах. Когда корпус бешено мчащейся машины отражает бесчисленные проносящиеся мимо фонари, дома и билборды – это действительно завораживающее зрелище, вот только подобная красота очень пагубно сказывается на драгоценных FPS; но, согласитесь, она того стоит!
Итак, давайте рассмотрим, как в современных играх добиваются эффекта зеркалирования. Существует несколько способов, различающихся по сложности выполнения и реалистичности. Первое, что придет на ум завзятому любителю Blender или любого другого трехмерного редактора – это использование метода трассировки лучей (ray tracing). Представьте, что камера испускает особый луч, который перемещается по сцене и отражается от зеркальных поверхностей, при этом постепенно аккумулируя найденные цвета объектов. Все это продолжается до тех пор, пока на пути такого луча не встретится «глухой» объект или не выйдет отпущенное для трассировки время. Картинка, обработанная таким способом, выглядит чрезвычайно эффектно и правдоподобно. Однако те, кто работал с методом трассировки в трехмерных редакторах, хором пожалуются на чрезвычайно долгую отрисовку имеющейся сцены. Увы, даже используя всю мощь шейдеров и процессоров видеоплат, мы не сможем добиться значительных FPS. За этим способом имеется будущее, но оно не скоро настанет. Поэтому чаще всего в приложениях реального времени применяется метод использования кубических карт.
Как и многое в мире игр, эффект отражения представляет собой просто трюк, так что можете назвать это надувательством. Давайте учиться, как можно качественно обманывать доверчивых игроков.
Суть метода заключается в том, что на модель натягивается заранее подготовленная текстура, которая и принимается зрителем за реальное отражение окружения. Несмотря на свою простоту, этот способ очень эффективно имитирует отражение.
Кубическая текстура или, как ее еще называют, environment map, представляет собой набор из шести картинок, отрисованных так, чтобы в совокупности охватить все имеющееся окружение вокруг объекта. Поскольку, находясь внутри этого куба, невозможно различить стыки на гранях, то подобный способ используется в играх и для построения фона (к примеру, неба, плавно переходящего на горизонте в горную цепь). Эти текстуры сохраняются в одном-единственном файле (рис. 3).
Основные расчеты для реализации эффекта отражения ложатся на плечи вершинного шейдера: фрагментный занимается лишь конечным наложением текстуры по полученным результатам вычислений. Несмотря на кажущуюся сложность эффекта отражения, алгоритм его реализации чрезвычайно простой.
Вначале вершинный шейдер должен вычислить координаты вершины в пространстве обзора. Для этого используем стандартный способ преобразования с помощью текущей матрицы модели:
vec4 Position = gl_ModelViewMatrix * gl_Vertex;
Чтобы текстура «смотрела» всегда в сторону камеры и не сдвигалась вместе с объектом, необходима следующая строка:
vec3 Position2 = Position.xyz / Position.w;
Теперь пора заняться освещением и эффектом рефракции (отражения). Для этого понадобятся вектор, определяющий направление обзора, и преобразованная нормаль поверхности:
vec3 ViewVec = normalize(-Position2); vec3 Norm = normalize(gl_NormalMatrix * gl_Normal);
Для вычисления эффекта отражения понадобится встроенная функция refract, параметрами которой служат направление просмотра, вектор нормали и коэффициент преломления. Воспользуемся константой Eta для хранения величины преломления:
const float Eta = 0.0; Refract = refract(ViewVec, Norm, Eta);
Полученное значение Refract будет передаваться в качестве varying-переменной фрагментному шейдеру. Конечный код вершинного шейдера таков:
//GLSL vertex shader varying vec3 Refract; const float Eta = 0.0; void main() { ec4 Position = gl_ModelViewMatrix * gl_Vertex; vec3 Position2 = Position.xyz / Position.w; vec3 ViewVec = normalize(-Position2); vec3 Norm = normalize(gl_NormalMatrix * gl_Normal); Refract = refract(ViewVec, Norm, Eta); gl_Position = ftransform(); }
После вычисления вектора преломления остается только вызвать нужный пиксель из кубической текстуры. Эти заботы GLSL берет на себя, поэтому конечный код фрагментного шейдера получается донельзя простым:
//GLSL fragment shader varying vec3 Refract; uniform samplerCube map; void main() { vec3 Color = vec3(textureCube(map, Refract)); gl_FragColor = vec4(Color, 1.0); }
На этом увлекательное, искренне надеюсь, путешествие в мир шейдеров завершено. Конечно, была рассмотрена совсем небольшая, можно даже сказать – ничтожная часть возможностей шейдеров, но ведь нашей главной целью было показать, что этот зверь не такой уж страшный и приручить его вполне возможно. Создание шейдеров – долгий и кропотливый труд, но воздается он сторицей, которая воплощается в виде потрясающих графических эффектов. Дерзайте! LXF