WebGL Урок 7 — Основы фонового и направленного освещения

Урок 8 >> << Урок 6

Материал в оригинале можно найти здесь

Добро пожаловать на мой седьмой урок по WebGL, основанный на неосвещенной в предыдущем уроке части Урок 7 учебника NeHe по OpenGL. Мы рассмотрим, как добавить простое освещение к вашей странице WebGL. Оно устроено немного сложнее в WebGL, чем в OpenGL, но надеюсь, что все будет достаточно понятным.

Вот как выглядит результат урока в браузере с поддержкой WebGL:

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

Используйте флажок под элементом canvas для включения и отключения освещения, чтобы видеть эффект, производимый освещением. Вы также можете менять цвет фонового и направленного освещения (позже я расскажу, что именно это обозначает) и направление последнего. Попробуйте поиграть с ними немного. Особенно весело смотреть на эффект от установки значений RGB больших единицы в направленном освещении (однако, при значениях больше 5 сложно будет различить текстуру). Также, как и в прошлом уроке, вы можете использовать стрелки для придания все большего вращения и клавиши Page Up и Page Down для приближения и удаления. Клавиша F больше не используется, так как мы уже используем самый лучший фильтр текстуры.

Теперь о том, как это все работает…

Уже обычное предупреждение: эти уроки ориентированы на людей с некоторым знанием программирования, но без опыта работы с 3D-графикой. С хорошим пониманием того, что происходит в коде, вы быстро начнете писать собственные 3D веб-страницы. Если вы не прочитали предыдущие уроки, возможно, вам следует сделать это перед чтением урока, где я буду объяснять лишь разницу между шестым уроком и этим.

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

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

Прежде чем углубиться в подробности создания освещения в WebGL, я сразу скажу плохие новости. В WebGL совершенно отсутствует встроенная поддержка света. В отличие от OpenGL, которая позволяет вам указать по крайней мере 8 источников света и затем управлять ими всеми, WebGL возлагает всю работу на ваши плечи. Но — и это очень значительное «но» — освещение становится довольно простым, стоит только объяснить его. Если вы разобрались с предыдущими уроками, связанными с шейдерами, у вас не будет проблем с освещением. И с написанием кода для несложного освещения, пока вы еще новичок, к вам придет понимание того, какой код нужно написать для более продвинутых случаев. К тому же, освещение в OpenGL слишком элементарное для действительно реалистичных сцен — например, оно не обрабатывает тени и дает довольно грубый эффект на изогнутых поверхностях — поэтому все то, что находится за пределами простых сцен, нуждается в ручной работе.

Итак, давайте подумаем, что мы хотим от освещения. Наша цель — возможность симулировать несколько источников света в пределах сцены. Сами источники не должны быть видны, но они должны реалистично освещать 3D-объекты, чтобы направленная к свету сторона объекта была светлой, а обратная сторона — темной. Другими словами, мы хотим указать несколько источников света, а затем для каждой части 3D-сцены вычислить, как на нее влияет весь свет в сумме. К настоящему моменту, я уверен, вы обладаете достаточными знаниями WebGL, чтобы определить, что здесь замешаны шейдеры. В частности, результатом нашей работы в рамках текущего урока будет написание вершинного шейдера, который обрабатывает свет. Мы вычислим, как свет влияет на каждую вершину, и используем эти знания для регулировки ее цвета. Сейчас мы будем проводить вычисления для одного источника света. Для нескольких источников нужно будет просто повторить эту процедуру для каждого из них и сложить результат вместе.

Небольшое отступление. Из-за того, что мы вычисляем освещение для каждой вершины, результат для пикселей, которые лежат между вершинами, будет вычислен линейной интерполяцией. Это означает, что пространство между вершинами будет считаться плоским при освещении. К нашему удобству, это именно то, что нужно, так как мы рисуем куб! Для изогнутых поверхностей, где вы хотите вычислить освещение для каждого пикселя независимо, вы можете использовать технику под названием пофрагментное (или попиксельное) освещение, которая дает гораздо более хорошие эффекты. Мы рассмотрим попиксельное освещение в будущем уроке. Сейчас же мы будем использовать технику под вполне логичным названием вершинное освещение.

Хорошо, идем дальше. Что мы будем делать, если нам предстоит вычислить, как единственный источник света влияет на цвет вершины? Хорошей отправной точкой в понимании будет Затенение по Фонгу. Наиболее простым путем к пониманию для меня стали следующие пункты:

  • В реальном мире есть один тип света, но в графике удобно считать, что существует два типа:
    1. Свет определенного направления, который освещает объекты, которые расположены на его пути. Назовем его направленное освещение
    2. Свет, который идет отовсюду и освещает все одинаково, независимо от того, куда объект повернут. Это называется фоновое освещение. (В реальном мире это просто направленный свет, рассеянный отражением от других объектов — воздуха, пыли и так далее. Но для наших целей мы представляем их как отдельные модели).
  • Когда свет достигает поверхности, он отражается двумя разными способами:
    1. Рассеивается. Независимо от угла, под которым падает свет, он равномерно отражается во всех направлениях. Неважно, под каким углом вы на него смотрите. Яркость отражения света определяется только углом падения света — чем круче угол наклона, тем тусклее отражение. Рассеянное отражение — то, о чем мы думаем, когда представляем зажженный объект.
    2. Бликует. Его можно сравнить с зеркалом. В этом случае пучок света отражается от поверхности под тем же углом, под которым он упал. Здесь яркость отраженного от материала света зависит от того, находятся ли ваши глаза на линии отраженного света. То есть не только от угла падения света на поверхность, но и от угла между вашей линией взгляда и поверхностью. Блики — это отблески и «зайчики» на объектах и степень отражения бликов меняется в зависимости от материала. Шершавое дерево будет плохо бликовать, в то время как отполированный металл очень хорошо.

Модель Фонга добавляет еще один виток к нашей четырехкомпонентной системе, который добавляет два свойства для света:

  1. Значения RGB для производимого рассеянного освещения.
  2. Значения RGB для производимых бликов.

…и четыре свойства для материалов:

  1. Значения RGB для отраженного фонового цвета.
  2. Значения RGB для отраженного рассеянного цвета.
  3. Значения RGB для отраженных бликов.
  4. Блеск объекта, который определяет характеристики бликов.

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

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

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

Свет, который идет по направлению к поверхности из одного источника, может быть двух типов: простое направленное освещение, которое имеет одно направление по всей сцене, и освещение, которое идет из точки сцены (и имеет разные углы в разных местах).

В случае простого направленного освещения углы, под которыми свет падает на вершины поверхности, — в точках A и B на рисунке — всегда одинаковые. Представьте свет от Солнца. Его лучи параллельны.

Если же свет идет из точки внутри сцены, угол падающего света будет разный в каждой вершине. В точке A на этом втором рисунке угол равен примерно 45 градусам, в то время как в точке B угол составляет примерно 90 градусов по отношению к поверхности.

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

Что ж, мы немного разобрались в проблеме. Мы знаем, что весь свет нашей сцены будет приходить из определенного направления, и это направление не будет меняться от вершины к вершине. Это означает, что мы можем поместить значение в uniform-переменную, чтобы шейдер смог его получить. Нам также известно, что производимый светом эффект будет зависеть от угла падения света на поверхность объекта в вершине, поэтому нам как-то нужно представить направление поверхности. Лучший способ для этого в 3D-геометрии — указать вектор нормали к поверхности в вершине. Это позволит нам указать направление поверхности в виде трех чисел. (В 2D-геометрии мы могли бы использовать касательную — направление самой поверхности в вершине — но в 3D-геометрии касательная может проходить в двух направлениях, поэтому нам понадобится два вектора для ее описания, ведь нормаль позволяет нам использовать только один вектор).

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

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

Фух! Довольно много теории для начала, но теперь мы знаем, что для создания простого направленного освещения нам нужно:

  • Хранить набор векторов нормалей — по одному для каждой вершины.
  • Иметь вектор направления света.
  • Рассчитать в шейдере скалярное произведение нормали вершины и вектора света, и определить величины цветов, не забывая добавить компонент для фонового освещения.

Теперь давайте взглянем на то, как это работает в коде. Мы начнем снизу и будем подниматься вверх по коду. Очевидно, HTML-код этого урока отличается от предыдущего, потому что у нас появились поля ввода, но я не буду утомлять вас этими подробностями… Перейдем к JavaScript, где нашей первой остановкой будет функция initBuffers. Здесь, сразу после кода создания буфера координат вершин, но перед аналогичным кодом для координат текстур, вы увидите код настройки нормалей. Этот стиль кода должен быть вам уже довольно знаком:


    cubeVertexNormalBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
    var vertexNormals = [
      // Front face
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,

      // Back face
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,

      // Top face
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,

      // Bottom face
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,

      // Right face
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,

      // Left face
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
    ];
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexNormals), gl.STATIC_DRAW);
    cubeVertexNormalBuffer.itemSize = 3;
    cubeVertexNormalBuffer.numItems = 24;

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


    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, cubeVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

Сразу после этого, по-прежнему в drawScene, находится код, в котором больше не нужно переключение текстур из шестого урока, и осталась только одна текстура:


    gl.bindTexture(gl.TEXTURE_2D, crateTexture);

Следующий фрагмент немного сложнее. Сначала мы смотрим, отмечен ли флажок освещения (Use lighting), и устанавливаем соответствующую uniform-переменную для шейдера:


    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(shaderProgram.useLightingUniform, lighting);

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


    if (lighting) {
      gl.uniform3f(
        shaderProgram.ambientColorUniform,
        parseFloat(document.getElementById("ambientR").value),
        parseFloat(document.getElementById("ambientG").value),
        parseFloat(document.getElementById("ambientB").value)
      );

Теперь мы передаем направление освещения:


      var lightingDirection = [
        parseFloat(document.getElementById("lightDirectionX").value),
        parseFloat(document.getElementById("lightDirectionY").value),
        parseFloat(document.getElementById("lightDirectionZ").value)
      ];
      var adjustedLD = vec3.create();
      vec3.normalize(lightingDirection, adjustedLD);
      vec3.scale(adjustedLD, -1);
      gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD);

Вы можете заметить, что мы настраиваем вектор направления света до передачи его в шейдер, используя модуль vec3, который является частью библиотеки glMatrix, как и mat4, который мы использовали для нашей матрицы модель-вид и проекционной матрицы. Первая настройка — vec3.normalize — растягивает его или сжимает до единичной длины. Вспомните, что два вектора должны быть единичной длины, чтобы скалярное произведение равнялось косинусу угла между ними. Все нормали, определенные нами ранее, имеют правильную длину, но так как направление света задает пользователь (и было бы жестоко заставлять его задавать уже нормализованный вектор), мы преобразуем этот вектор. Второй настройкой будет умножение вектора на -1 — то есть изменение его направления на противоположное. Это необходимо, потому что мы указываем, куда свет идет, а для расчетов, которые мы рассматривали ранее, нам нужно знать, откуда идет свет. После этих настроек мы передаем направление в шейдеры через функцию gl.uniform3fv, которая помещает трехэлементный Float32Array (то, с чем работает функция vec3) в uniform-переменную.

Следующий отрывок кода проще, он просто копирует цветовые компоненты направленного освещения в соответствующую uniform-переменную шейдера:


      gl.uniform3f(
        shaderProgram.directionalColorUniform,
        parseFloat(document.getElementById("directionalR").value),
        parseFloat(document.getElementById("directionalG").value),
        parseFloat(document.getElementById("directionalB").value)
      );
    }

Вот и все изменения в drawScene. Перемещаясь вверх к коду по управлению клавиатурой, мы найдем небольшие изменения по удалению обработчика для клавиши F, которые мы можем проигнорировать. Следующее интересное изменение находится в функции setMatrixUniforms, которая, как вы помните, копирует матрицу модель-вид и проекционную матрицу в uniform-переменные шейдера. Мы добавили четыре строки для копирования новой матрицы на основании матрицы модель-вид:


    var normalMatrix = mat3.create();
    mat4.toInverseMat3(mvMatrix, normalMatrix);
    mat3.transpose(normalMatrix);
    gl.uniformMatrix3fv(shaderProgram.nMatrixUniform, false, normalMatrix);

Как вы могли догадаться по переменной под названием «матрица нормалей» (normalMatrix), здесь идет преобразование нормалей :). Мы не можем преобразовывать их по той же схеме, что была использована для координат вершин, используя обычную матрицу модель-вид, потому что нормали будут изменены как переносом, так и поворотом. Например, если мы проигнорируем поворот и выполним перенос функцией mvTranslate на (0, 0, -5), то нормаль (0, 0, 1) станет (0, 0, -4), что не только сделает ее слишком длинной, но и изменит ее направление на противоположное. Мы могли бы обойти это. Вы могли заметить, что в вершинных шейдерах при умножении трехэлементной матрицы координат вершин на матрицу модель-вид размером 4х4, чтобы сделать их совместимыми, мы расширяем координаты вершин до четырехэлементной матрицы добавлением 1 последним элементом. Эта 1 нужна не только для дополнения матрицы, но и для того, чтобы при умножении учитывались переносы, повороты и другие преобразования. Так случилось, что при добавлении 0 вместо 1 мы добьемся того, что умножение будет игнорировать перенос. Сейчас этот способ отлично нам подойдет, но, к сожалению, не сработает в случаях различных трансформаций матрицы модель-вид — например, масштабирования и сдвига. Например, при увеличении размера объекта в два раза длина нормалей также увеличится в два раза, не смотря на 0 в конце, и это станет причиной больших проблем с освещением. Поэтому не будем вырабатывать плохую привычку и сделаем сразу все правильно :).

Правильный путь для получения нормалей, указывающих в нужных направлениях, — это использовать верхнюю левую часть 3х3 от матрицы модель-вид, затем найти от нее обратную и транспонировать. Более подробно можно почитать здесь, а также может оказаться полезным комментарии Coolcat (которые он оставил к предыдущим версиям урока). (Спасибо также Shy за дополнительные советы).

В любом случае, после того, как мы рассчитали эту матрицу и сделали необходимую магию, она помещается в uniform-переменные шейдера, как и другие матрицы.

Двигаясь отсюда вверх по коду, мы найдем еще несколько тривиальных изменений в коде загрузки текстур, так как осталась только одна мипмап-текстура вместо списка из трех текстур, еще в initShaders добавился код для инициализации атрибута vertexNormalAttribute, чтобы функция drawScene смогла передавать через него нормали к шейдерам и выполнять похожие действия для всех появившихся uniform-переменных. Ни одно из этих изменений не стоит детального рассмотрения, поэтому двинемся сразу к шейдерам.

Фрагментный шейдер проще, поэтому рассмотрим сначала его:


  precision mediump float;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  uniform sampler2D uSampler;

  void main(void) {
     vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
     gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a);
  }

Как видите, мы получаем цвет текстуры, как и в шестом уроке, но перед возвратом мы корректируем значения R, G и B с помощью varying-переменной vLightWeighting. vLightWeighting — это трехэлементный вектор и содержит (как вы могли ожидать) корректирующий коэффициент для красного, зеленого и синего цвета, которые получены от источника цвета в вершинном шейдере.

Как же это работает? Давайте взглянем на код вершинного шейдера. Новый код выделен красным:


  attribute vec3 aVertexPosition;
  attribute vec3 aVertexNormal;
  attribute vec2 aTextureCoord;

  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;
  uniform mat3 uNMatrix;

  uniform vec3 uAmbientColor;

  uniform vec3 uLightingDirection;
  uniform vec3 uDirectionalColor;

  uniform bool uUseLighting;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  void main(void) {
    gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
    vTextureCoord = aTextureCoord;

    if (!uUseLighting) {
      vLightWeighting = vec3(1.0, 1.0, 1.0);
    } else {
      vec3 transformedNormal = uNMatrix * aVertexNormal;
      float directionalLightWeighting = max(dot(transformedNormal, uLightingDirection), 0.0);
      vLightWeighting = uAmbientColor + uDirectionalColor * directionalLightWeighting;
    }
  }

Новый атрибут, aVertexNormal, конечно же, содержит нормали вершин, которые мы указали в initBuffers и передали в шейдер в drawScene. uNMatrix — наша матрица нормалей, uUseLighting — uniform-переменная, указывающая, включено ли освещение, и, наконец, uAmbientColor, uDirectionalColor и uLightingDirection — значения, введенные пользователем в поля ввода веб-страницы.

В свете математики, которую мы рассмотрели выше, само тело кода должно быть довольно простым для понимания. Главная выходная переменная вершинного шейдера — varying-переменная vLightWeighting, которая, как мы только что видели, корректирует цвет изображения во фрагментном шейдере. Если освещение выключено, мы просто используем значение по умолчанию (1, 1, 1), что означает, что цвета не должны меняться. Если освещение включено, мы вычисляем направления нормалей применением матрицы нормалей и затем находим скалярное произведение нормали и направления света для получения значения для количества отражаемого света (с минимальным значением ноль, как я уже говорил). Затем мы можем вычислить окончательные значения для фрагментного шейдера через умножение компонент цвета направленного освещения на соответствующие коэффициенты и затем через добавление цвета фонового освещения. Полученный результат — это именно то, что нужно фрагментному шейдеру, поэтому мы закончили!

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

Если у вас есть вопросы, комментарии или поправки, прошу оставлять их в комментариях ниже!

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

Урок 8 >> << Урок 6

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *