Изучаем OpenGL ES2 под Андроид. Урок 2 — Окружающее и рассеянное освещение.

Урок второй Работа со светом

 Добро пожаловать во второй урок посвященный OpenGL ES2  для Android. В этом уроке мы узнаем как реализовать Ламбертово отражение используя шейдеры, оно также известно, как рассеянное освещения. В OpenGL ES 2, мы должны реализовать наши собственные алгоритмы освещения, таким образом, мы узнаем, как это описывается математически и как мы можем применить это к нашей 3D сцене.

Предположения и предпосылки

Каждый урок в этой серии основан на предыдущих уроках. Поэтому, прежде чем начать, ознакомьтесь с материалом первого урока , т.к. второй урок будет строиться на принципах и структуре описанных в нем.

Что же такое свет?

Мир без света будет действительно мрачным. Без света мы бы даже не смогли видеть мир и объекты расположенные вокруг нас. В нашем распоряжении оставались бы лишь такие органы чувств как слух и осязание. Свет показывает нам насколько яркий или тусклый предмет, насколько близко или далеко он находится от нас и под каким углом лежат его плоскости.

В реальном мире все, что мы воспринимаем как свет в действительности поток триллионов крошечных частиц, называемых фотонами, которые вылетают из источника света, отражаются тысячи или миллионы раз, и в конечном итоге попадают в наши глаза, где мы и воспринимаем их как свет.

Как же мы сможем имитировать эффекты света с помощью компьютерной графики? Есть два популярных способа описывающих как сделать это: трассировка лучей и растеризация . Трассировка лучей заключается в математическом отслеживании луча света и его взаимодействии с поверхностью на которую он падает. Этот метод дает очень точные и реалистичные результаты, но недостатком является то, что моделирование всех этих лучей требует очень больших вычислительных затрат, и, как правило, работает слишком медленно для рендеринга в реальном времени. В связи с этим ограничением, для большинства изображений в реальном времени используется компьютерная растеризация, которая имитирует освещение путем аппроксимации результатов. Учитывая реализм графики в последних играх, растеризация дает довольно хороший результат и работает достаточно быстро, при обработке графики в реальном времени, даже на мобильных устройствах. Open GL ES, прежде всего основан на библиотеке растеризации, поэтому мы сосредоточим наше внимание именно на ней.

Существующие виды освещения

Если мы сможем абстрагироваться неким образом, и отсортировать свет по его принципу излучения, то мы сможем придумать три основных типа освещения:

окружающее освещение в Opengl ESОкружающее(фоновое) освещение

Это базовый уровень освещения, который, кажется, пронизывает всю сцену. Это свет который не имеет явно видимого источника света, так лучи прежде, чем добраться до вас, отразились множество раз. Этот тип освещения можно сравнить со светом на улице в пасмурный день или в помещении, как результирующий свет от различных источников освещения. Вместо вычисления уровня свечения для отдельных источников, можно просто установить базовый уровень света для всего объекта или сцены.

рассеянное освещение Рассеянное(диффузное) освещение

Это свет, который достигает ваших глаз после отражения непосредственно от объекта. Уровень освещенности объекта, зависит от его угла освещения. То, что перпендикулярно к лучам света, светится ярче, чем то, на что лучи падают под углом. Кроме того, мы воспринимаем объект с той же яркостью независимо от того, под каким углом мы находимся по отношению к объекту. Этот принцип известен как как закон косинуса Ламберта . Рассеянное освещение или отражение Ламберта распространено в повседневной жизни, его можно легко увидеть на белой стене, на которую падает свет.

Зеркальное отражение света

Зеркальное(бликовое) освещение

В отличие от рассеянного освещения, зеркальное изменяет свое освещения, в зависимости от того, как мы движемся по отношению к объекту. Он дает «блики» на объекте и его можно увидеть на «гладкой» поверхности, такой как стекло и на других блестящих предметах.

 

Моделирование света

Также как существует три основных типа освещения для 3D сцен, также есть три основных типа источников света: направленный, в виде светящейся точки, и точечный источник с рассеиванием в виде конуса. Их также можно легко наблюдать в повседневной жизни. 

направленное освещениеНаправленный источник света

Направленное освещение обычно исходит от яркого источника, который находится так далеко, что он освещает всю сцену равномерно и с одинаковой яркостью. Этот источник простейшего типа, свет одинаковой силы и его направление не зависит от того, где вы находитесь.

 

светящаяся точкаТочечное свечение

Источники света в виде точек могут быть добавлены к сцене, чтобы сделать освещение более разнообразным и реалистичным. Освещенность от такого источника света убывает с расстоянием, а его лучи расходятся во всех направлениях от центра источника света.

 

точечный источник светаТочечный источник света

В дополнение к свойствам светящейся точки этот источник света имеет ослабляющее направление свечения, как правило в форме конуса.

 

 

Математика

В этом уроке мы рассмотрим окружающее освещение и рассеянное освещение, а также освещение исходящее от точечного источника света.

Окружающее освещение

Окружающее освещение дает эффект как от непрямого рассеянного освещения, но его также можно рассматриваться как свет слабой интенсивности, который пронизывает всю сцену. Если мы возьмем за основу это предположение, то вычисления становятся очень простыми:

final color = material color * ambient light color

Например, предположим, что наш объект красного цвета и наш окружающий свет тускло белый. Давайте предположим, что мы храним цвета в виде массива из трех цветов: красного, зеленого и синего цвета, используя цветовую модель RGB:

final color = {1, 0, 0} * {0.1, 0.1, 0.1} = {0.1, 0.0, 0.0}

Окончательный цвет объекта будет тускло красным, что и следовало ожидать, ведь у нас красный объект освещенный тусклым белым светом. Больше нет ничего сложного, если вы не хотите изучить более продвинутые методы освещения, такие как алгоритм Radiosity.

Рассеянное освещение — точечный источник света

Для рассеянного освещения нам нужно учитывать затухание в зависимости от положения источника света. Позиции источников света будут использованы для вычисления угла между световым лучом и предметами, это все будет влиять на общий уровень освещенности поверхности. Они также могут быть использованы для вычисления расстояния между источником света и поверхностью, которая покажет интенсивность света в каждой точке.

Шаг 1: Вычисляем фактор Ламберта.

Первый сложный расчет, который нам нужно сделать — определить угол между поверхностью и источником света. Поверхность, которая перпендикулярна к источнику света, должна освещаться максимально, в то время, как поверхность, расположенная под углом должна получить меньше освещения. Как правильно рассчитывают это с помощью закона косинуса  Ламберта . Для этого нам нужно получить два вектора, один из которых направлен от источника света до точки на поверхности, а второй к нормали поверхности (если поверхность плоская, то поверхность нормалей является вектором, указывающим прямо вверх, или расположенный ортогонально к этой поверхности ). Зная это, мы можем вычислить косинус, но вначале нам нужно нормализовать каждый вектор так, что он имел длину равную 1, а затем, мы проводим вычиления скалярного произведения двух векторов. Эта операция может быть легко выполнена с помощью шейдера OpenGL ES 2. В нем уже предусмотрены необходимые методы и параметры для таких расчетов. Например скалярное произведение векторов описывается командой dot.

Назовем наш нормализованный коэффициент фактором Ламберта (lamber factor) , он будет в диапазоне от 0 до 1.

light vector = light position - object position
cosine = dot product(object normal, normalize(light vector))
lambert factor = max(cosine, 0)

Вначале вычисляем вектор освещения путем вычитания от вектора положения источника света положение объекта. Мы получим значение косинуса, выполнив скалярное произведение нормализованного вектора объекта и нормализованного вектора света. Нормализация вектора света означает то, что мы изменяем его длину таким образом чтобы она равнялась единице. Нормализованный Объект уже должны иметь длину равную единице. Скалярное произведения двух нормированных векторов дает нам косинус угла между ними. Поскольку значение может быть в диапазоне от -1 до 1 , то мы ограничиваем его до диапазона от 0 до с помощью оператора max.

Вот пример расчета для плоской поверхности расположенной в начале координат и с вектором нормали к поверхности направленным вверх к небу. Источник света расположен в точке {0, 10, -10} Нам необходимо вычислить освещенность в начале координат.

light vector length = square root(0*0 + 10*10 + -10*-10) = square root(200) = 14.14
normalized light vector = {0, 10/14.14, -10/14.14} = {0, 0.707, -0.707}

На простом языке, если мы будем двигаться вдоль светового вектора, мы достигнем положения источника света. Для нормализации вектора, нам необходимо разделить каждый компонент на длину вектора:

light vector length = square root(0*0 + 10*10 + -10*-10) = square root(200) = 14.14
normalized light vector = {0, 10/14.14, -10/14.14} = {0, 0.707, -0.707}

Затем выполняем скалярное произведение:

dot product({0, 1, 0}, {0, 0.707, -0.707}) = (0 * 0) + (1 * 0.707) + (0 * -0.707) = 0 + 0.707 + 0 = 0.707

Вот хорошее объяснение скалярного произведения и что он вычисляет . Далее мы ограничиваем диапазон:

lambert factor = max(0.707, 0) = 0.707

Еще раз отмечу, что язык шейдеров в OpenGL ES 2 имеет уже встроенную поддержку для некоторых из этих функций, поэтому нам не нужно делать все математические вычисления вручную, пример расчета был дан для понимания того, что же там происходит.

Шаг 2: Расчет коэффициента затухания интенсивности света.

Далее, нам необходимо рассчитать процесс затухания. В практике затухание света от точечного источника света описывается законом обратных квадратов, который можно сформулировать так:

luminosity = 1 / (distance * distance)

Возвращаясь к нашему примеру, где у нас задано расстояние равное 14.14 Вот какое окончательное значение освещенности мы получим:

luminosity = 1 / (14.14*14.14) = 1 / 200 = 0.005

Как вы видите, закон обратных квадратного может привести к сильным затуханием на расстоянии. Это похоже на свет от точечного источника в реальном мире, но так как наш графический экран имеет ограниченные размеры, нам придется ослабить этот коэффициент затухания так, чтобы мы получили реалистичное освещение без слишком темных предметов.

Шаг 3: Вычисление результирующего цвета.

Теперь, когда у нас есть и косинус и затухание, мы можем вычислить наш конечный уровень освещенности:

final color = material color * (light color * lambert factor * luminosity)

Согласно с нашим предыдущим примером у нас материал красного цвета и полностью белого цвета источник света. Вот окончательный расчет:

final color = {1, 0, 0} * ({1, 1, 1} * 0.707 * 0.005}) = {1, 0, 0} * {0.0035, 0.0035, 0.0035} = {0.0035, 0, 0}

Напомним, что для рассеянного освещения мы должны использовать угол между поверхностью и источником света, а также учитывать расстояние между поверхностью и источником свет для того, чтобы получить окончательный уровень общего рассеянного уровня освещенности. Вот последовательность наших действий:

//Шаг первый
light vector = light position - object position
cosine = dot product(object normal, normalize(light vector))
lambert factor = max(cosine, 0)

//Шаг второй
luminosity = 1 / (distance * distance)

//Шаг третий
final color = material color * (light color * lambert factor * luminosity)
Сводим все в шейдеры OpenGL ES 2

Вершинный шейдер

final String vertexShader =
    "uniform mat4 u_MVPMatrix;      \n"     // Константа содержащая значения матрицы model/view/projection.
  + "uniform mat4 u_MVMatrix;       \n"     // Константа содержащая значение матрицы model/view
  + "uniform vec3 u_LightPos;       \n"     // Положение источника света в пространстве.

  + "attribute vec4 a_Position;     \n"     // Информация о положении вершин.
  + "attribute vec4 a_Color;        \n"     // Информация о цвете вершин.
  + "attribute vec3 a_Normal;       \n"     // Информация о нормали вершин.

  + "varying vec4 v_Color;          \n"     // Помещаем во фрагментный шейдер.

  + "void main()                    \n"     // Начало программы вершинного шейдера.
  + "{                              \n"
// Преобразование вершин к видимому пространству.
  + "   vec3 modelViewVertex = vec3(u_MVMatrix * a_Position);              \n"
// Преобразование к нормали ориентации видимого пространства.
  + "   vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));     \n"
// Расстояние - используется для расчета затухания.
  + "   float distance = length(u_LightPos - modelViewVertex);             \n"
// Получаем направленный нормализованный вектор от источника света к вершине.
  + "   vec3 lightVector = normalize(u_LightPos - modelViewVertex);        \n"
// Вычисляем скалярное произведение вектора света и вектора нормали, если они
// направлены в одну и туже сторону мы получаем макс. освещение.
  + "   float diffuse = max(dot(modelViewNormal, lightVector), 0.1);       \n"
// Получаем коэффициент затухание света в зависимости от расстояния.
  + "   diffuse = diffuse * (1.0 / (1.0 + (0.25 * distance * distance)));  \n"
// Цвет с учетом уровня освещенности. Значения будут интерполированы с помощью треугольников.
  + "   v_Color = a_Color * diffuse;                                       \n"
// gl_Position специальная переменная используемая для хранения окончательного расположения.
// Перемножаем вершины на матрицы для получения окончательной точки в координатах нормализованного пространства экрана.
  + "   gl_Position = u_MVPMatrix * a_Position;                            \n"
  + "}                                                                     \n";

Немного о том, что здесь просходит. У нас есть комбинированная матрица модель/вид/проекция, такая же как в первом уроке, но мы добавили еще матрицу модель/вид. Для чего? Нам нужна эта матрицу для того, чтобы рассчитать расстояние между положением источника света и положением текущей вершины. Для рассеянного освещения, на самом деле, это не имеет значения, используете ли вы все пространство (матрица модели) или наблюдаемое глазом(камерой) пространство (матрица модель/проекция).

Передаем цвет вершин и информацию об их местоположении, а также значения их нормалей. Далее передаем результирующий цвет в фрагментный шейдер, который будет интерполировать(вычислять усредненный) цвет всех остальных точек между его вершинами. Этот прием также известен как тонирование Гуро.

Давайте посмотрим на каждую часть шейдера, чтобы узнать, что происходит:

// Преобразование вершин к видимому глазу миру.
  + "   vec3 modelViewVertex = vec3(u_MVMatrix * a_Position);              \n"

Поскольку мы передаем значение о положении источника света в наблюдаемое глазом пространство, то преобразуем текущие координаты вершин к наблюдаемому пространству, чтобы мы могли рассчитать соответствующие расстояния и углы.

// Преобразуем нормализованный вид к наблюдаемому пространству.
  + "   vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));     \n"

 Мы также должны преобразовать ориентации нормали. Здесь мы просто делаем постоянное перемножение матриц, отвечающих за положение, но если матрица модели или проекции была урезана или искажена, это не сработает: нам тогда нужно будет отменить искажающие или маштабирущие действия путем умножения на исходную обратно нормально транспонированную матрицу. Данный сайт лучше всего объясняет, почему мы должны это сделать .

// Расстояние для расчета ослабления света.
  + "   float distance = length(u_LightPos - modelViewVertex);             \n"

Как было описано выше, в разделе Математика, нам необходимо знать расстояние для расчета коэффициента затухания.

// Получаем нормализованный вектор направленный от источника света к вершине.
  + "   vec3 lightVector = normalize(u_LightPos - modelViewVertex);        \n"

Нам также необходим этот вектора для расчета коэфициента Ламбертового отражения.

// Вычилсяем скалаярное произведение вектора напрвления света и вектора нормали. Если нормаль и вектор направления света
// направлены в одну сторону, то мы получаем максимальное освещение.
  + "   float diffuse = max(dot(modelViewNormal, lightVector), 0.1);       \n"

Это тот же расчет был описан ранее в разделе Математика, но теперь все вычисления выполняет шейдер OpenGL ES 2. Значение 0.1 в конце сообщает программе, что мы используем самый легкий способ задания внешнего освещения (все вычисленные значение будет ограничены величиной не менее 0,1).

// Ослабление света в зависимости от расстояния.
  + "   diffuse = diffuse * (1.0 / (1.0 + (0.25 * distance * distance)));  \n"

Расчет ослабления свечения немного отличается от описанного выше в разделе Математика. Мы умножаем квадрат расстояния на 0,25, чтобы ослабить эффект затухания, и добавляем 1,0, чтобы не было перенасыщения, когда свет находится очень близко возле объекта (иначе, когда расстояние будет меньше 1, уравнение выдаст освещение намного ярче, вместо его ослабления).

// Перемножаем цвет на уровень освещенности. Интерполируется через треугольники.
  + "   v_Color = a_Color * diffuse;                                       \n"
// gl_Position специальная переменная для хранения результирующего положения.
// Перемножаем вершины и матрицу для получения окончательной точки в нормализованных координатах экрана.
  + "   gl_Position = u_MVPMatrix * a_Position;                            \n"

Как только у нас есть окончательный цвет света, умножаем его на цвет вершин для получения результирующего цвета, а затем мы проектируем положение и цвет этой вершины на экран.

Фрагментный шейдер

final String fragmentShader =
  "precision mediump float;       \n"     // Устанавливаем среднюю точность. Большая точность
                                          // в фрагментных шейдерах не нужна.
+ "varying vec4 v_Color;          \n"     // Значение цвета вершинного шейдера интерполированного
                                          // фрагментными треугольниками.
+ "void main()                    \n"     // Точка входа во фрагментный шейдер.
+ "{                              \n"
+ "   gl_FragColor = v_Color;     \n"     // Передаем значение цвета.
+ "}                              \n";

Так как мы вычисляем уровень света для каждой вершины, наш шейдер выглядит так же, как и в первом уроке - все, что нам нужно сделать, это передать через него непосредственно сам цвет. В следующем уроке мы рассмотрим точечное (попиксельное) освещение.

По-вершинное по-пиксельное освещение

В этом уроке мы были сосредоточены на реализации освещения для каждой вершины. При диффузном (рассеянном) освещении объектов с гладкими поверхностями, такими как плоскость, или для объектов с множеством треугольников(имитация шероховатости поверхности), этого было достаточно. Однако, когда объекты не содержат множества вершин (например кубы в этом примере программы) или у них есть острые углы, вершинное освещения может привести к аномальным световым эффектам также и при линейной интерполяции освещенности по полигонам; эти аномалии также становятся более заметными при добавлении бликов к изображению. Больше можно почитать в статье по фильтрам Гуро.

Пояснения к изменениям в программе

Кроме добавление к каждой вершине уровня освещенности, есть и другие изменения в программе. Мы перешли от отображения трех треугольников к отображению пяти кубиков, а также добавили функционал для загрузки в шейдер. Появились также новые шейдеры, для отображения положения  источника света в виде точки, а также другие небольшие изменения.

Построение куба

В первом уроке, мы объединяли данные атрибутов о положении и цвете в один массив, но OpenGL ES 2 нам позволяет задавать эти значения и в отдельных массивах:

// X, Y, Z
final float[] cubePositionData =
{
        // В OpenGL направление обхода по умолчанию идет против часовой стрелки. Когда мы смотрим на треугольник,
        // если точки вершин идут против часовой стрелки, значит это передняя часть. Если же наоборот,
        // то задняя. OpenGL оптимизирован так, чтобы задняя часть отсеивалась так, как
        // обычно эта часть фигуры не видна.

        // Передняя(лицевая) плоскость
        -1.0f, 1.0f, 1.0f,
        -1.0f, -1.0f, 1.0f,
        1.0f, 1.0f, 1.0f,
        -1.0f, -1.0f, 1.0f,
        1.0f, -1.0f, 1.0f,
        1.0f, 1.0f, 1.0f,
        ...

// R, G, B, A
final float[] cubeColorData =
{
        // Передняя плоскость (красная)
        1.0f, 0.0f, 0.0f, 1.0f,
        1.0f, 0.0f, 0.0f, 1.0f,
        1.0f, 0.0f, 0.0f, 1.0f,
        1.0f, 0.0f, 0.0f, 1.0f,
        1.0f, 0.0f, 0.0f, 1.0f,
        1.0f, 0.0f, 0.0f, 1.0f,
        ...
Новое в OpenGL — флаги

Мы также включили очистку и буфер глубины(функцию анализа на перекрытие объектов друг друга в зависимости от удаленности относительно точки наблюдения) с помощью команды glEnable():

// Используем очистку для удаления невидимых плоскостей.
GLES20.glEnable(GLES20.GL_CULL_FACE);

// Включаем тест глубины Z-buffe.
GLES20.glEnable(GLES20.GL_DEPTH_TEST);

В целях оптимизации, мы можем сообщить OpenGL чтобы он удалял треугольники, которые находятся на задней стороне объекта. Когда мы описывали наш куб, мы также описали три координаты каждого треугольника в направлении против часовой стрелки(если смотреть на переднюю сторону). Когда мы вращаем куб, состоящий из треугольников, и смотрим на заднюю сторону, тогда точки читаются по ходу часовой стрелки. Мы не можем видеть больше трех сторон куба сразу, поэтому, что бы не тратить вычислительные мощности на просчет задней стороны, мы оптимизируем работу OpenGL и не рисуем треугольники, составляющие заднюю плоскость.

Позже, когда мы захотим рисовать прозрачные объекты, мы отключим очистку и тогда сможем видеть обратную сторону объектов.

Мы также включаем буфер глубины . Если вы всегда выводите объекты по расположению от дальнего к ближнему относительно точки наблюдения,то тестирование глубины не является строго обязательным, но, если его использовать, то вам не нужно будет беспокоится о порядке расположения предметов(иногда рендеринг(перерисовка) может работать быстрее, если вы прорисовываете вначале ближние объекты), но некоторые видеокарты в любом случае уже используют собственную оптимизацию, которая ускоряет рендеринг, увеличивая скорость отрисовки пикселей.

Изменения в загрузке программ шейдеров

Поскольку последовательность по загрузке шейдеров в OpenGL в основном схожа, эти шаги можно легко переместить в отдельный метод. Мы также добавим следующие вызовы для получения информации об ошибках в процессе компиляции:

GLES20.glGetProgramInfoLog(programHandle);
GLES20.glGetShaderInfoLog(shaderHandle);
Вершинные и шейдерные программы для точечного источника света

Ниже приведена новая вершинная и шейдерная программа для рисования точки на экране, соответствующей текущему положению источника света:

// Описываем простую шейдерную программу для нашей точки.
final String pointVertexShader =
    "uniform mat4 u_MVPMatrix;      \n"
  + "attribute vec4 a_Position;     \n"
  + "void main()                    \n"
  + "{                              \n"
  + "   gl_Position = u_MVPMatrix   \n"
  + "               * a_Position;   \n"
  + "   gl_PointSize = 5.0;         \n"
  + "}                              \n";

final String pointFragmentShader =
    "precision mediump float;       \n"
  + "void main()                    \n"
  + "{                              \n"
  + "   gl_FragColor = vec4(1.0,    \n"
  + "   1.0, 1.0, 1.0);             \n"
  + "}                              \n";

Этот код шейдера похожа на пример шейдера с первого урока. Здесь лишь новый параметр gl_PointSize, которому мы задали значение равное 5,0, это размер отрисовываемой точки в пикселях. Он используется, когда мы рисуем точку с помощью режима GLES20.GL_POINTS. Результирующий цвет при этом устанавливается в белый.

Дальнейшие упражнения (самостоятельная работа)

  • Попробуйте удалить «защиту от перенасыщения цветом» и посмотрите, что получится.
  • Существует некий недостаток, после создании окружающего освещения. Вы можете определить в чем он заключается?
  • Что произойдет, если добавить к шейдеру куба режим gl_PointSize и отрисовать все с помощью GL_POINTS?

Дополнительная литература (на английском)

Подводим итоги

Полный исходный код для этого урока можно загрузить с сайта проекта на GitHub.

Установочный файл приложения с этого урока можете скачать непосредственно с сайта Google Play.

Comments are closed.