WebGL Урок 13 — Попиксельное освещение и несколько программ

Урок 14 >> << Урок 12

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

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

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

Здесь можно посмотреть онлайн-демонстрацию, если ваш браузер поддерживает WebGL. Здесь можно узнать, что делать, если браузер не поддерживает WebGL. Вы увидите вращающиеся сферу и куб. Возможно, что оба этих объекта некоторое время будут белыми, пока текстуры не загрузятся, но после загрузки вы поймете, что сфера — это на самом деле Луна, а куб (масштаб не соблюден) — это деревянный ящик. Сцена похожа на ту, которая использовалась в двенадцатом уроке, но мы находимся ближе к вращающимся объектам, чтобы вы могли более четко видеть как они выглядят. Как и прежде, оба объекта освещены точечным источником освещения, находящимся между ними. Если вы желаете изменить положение источника, его цвет и другие параметры, под элементом canvas есть поля ввода и галочки для включения/выключения света, для переключения между вершинным и попиксельным освещением и для использования или отключения текстур.

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

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

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

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

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

Для начала разберемся, почему же попиксельное освещение сильнее нагружает графический процессор. Вероятно, вы помните рисунок из седьмого урока. Как вы знаете, яркость поверхности определяется углом между ее нормалью и падающим лучом света, исходящим из источника освещения. До этого момента расчет нашего освещения происходил в вершинном шейдере с помощью объединения указанных для каждой вершины нормалей с идущим из вершины направлением освещения. Это нам давало коэффициенты для света, которые через varying-переменную мы передавали из вершинного шейдера во фрагментный шейдер, где изменяли яркость цвета для соответствующего фрагмента. Этот световой коэффициент, как и все varying-переменные, линейно интерполируется системой WebGL на каждый фрагмент, лежащий между вершинами. Поэтому на рисунке точка B будет достаточно яркой, так как в ней свет параллелен нормали. Точка A будет более тусклой, так как свет в ней падает под большим наклоном. Точки между этими двумя точками будут равномерно переходить от светлого к темному, что выглядит именно так, как и должно.

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

Вычисление освещения для каждого фрагмента подразумевает, что нам нужно знать его местоположение (чтобы определить направление освещение) и его нормаль. Мы можем получить их, передавая их от вершинного шейдера к фрагментному. Они оба будут линейно интерполированы, поэтому координаты будут лежать вдоль прямой линии между вершинами, а нормали будут плавно изменяться. Прямая линия — это именно то, что нам нужно. А из-за того, что нормали в точках A и C одинаковы, то и нормали для всех фрагментов будут одинаковыми, что тоже нам отлично подходит.

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

Хорошо, довольно теории, начнем смотреть код! Шейдеры находятся вверху файла, поэтому начнем с них. Так как наш пример использует и вершинное, и попиксельное освещение, в зависимости от галочки «Per-fragment lighting», мы имеем вершинный и фрагментный шейдер для каждого типа освещения (было бы возможно обрабатывать оба типа освещения в шейдере, но в этом случае он бы стал тяжело читаем). Позже мы рассмотрим, как можно переключаться между шейдерами, а пока обратите внимания, что мы разделили шейдеры по разным скриптам и назначили им разные id. Первыми идут шейдеры вершинного освещения, и они ничем не отличаются от шейдеров седьмого урока, поэтому я оставлю только их тэги script, чтобы вы нашли их по ходу просмотра файла:


<script id="per-vertex-lighting-fs" type="x-shader/x-fragment">

<script id="per-vertex-lighting-vs" type="x-shader/x-vertex">

Теперь фрагментный шейдер для попиксельного освещения:


<script id="per-fragment-lighting-fs" type="x-shader/x-fragment">
  precision mediump float;

  varying vec2 vTextureCoord;
  varying vec3 vTransformedNormal;
  varying vec4 vPosition;

  uniform bool uUseLighting;
  uniform bool uUseTextures;

  uniform vec3 uAmbientColor;

  uniform vec3 uPointLightingLocation;
  uniform vec3 uPointLightingColor;

  uniform sampler2D uSampler;

  void main(void) {
    vec3 lightWeighting;
    if (!uUseLighting) {
      lightWeighting = vec3(1.0, 1.0, 1.0);
    } else {
      vec3 lightDirection = normalize(uPointLightingLocation - vPosition.xyz);

      float directionalLightWeighting = max(dot(normalize(vTransformedNormal), lightDirection), 0.0);
      lightWeighting = uAmbientColor + uPointLightingColor * directionalLightWeighting;
    }

    vec4 fragmentColor;
    if (uUseTextures) {
      fragmentColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
    } else {
      fragmentColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
    gl_FragColor = vec4(fragmentColor.rgb * lightWeighting, fragmentColor.a);
  }
</script>

Как видите, код очень похож на вершинные шейдеры, которые мы использовали ранее. Он выполняет те же расчеты для вычисления направления освещения и затем объединяет его с нормалью для вычисления «силы» света. Разница в том, что входные данные для этих расчетов теперь приходят из varying-переменных вместо вершинных атрибутов, и в том, что освещение сразу же объединяется с цветом текстуры вместо передачи далее для последующей обработки. Также стоит обратить внимания, что нам нужно нормализовать вектор интерполируемой нормали, содержащейся в varying-переменной. Нормализация, как вы помните, — это преобразование вектора к единичной длине. Это необходимо по причине того, что интерполяция между двумя единичными векторами не обязательно даст в результате также единичный вектор, а только вектор, который имеет верное направление. Нормализация устраняет этот недостаток. (спасибо Glut за указание этого в комментариях)

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


<script id="per-fragment-lighting-vs" type="x-shader/x-vertex">
  attribute vec3 aVertexPosition;
  attribute vec3 aVertexNormal;
  attribute vec2 aTextureCoord;

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

  varying vec2 vTextureCoord;
  varying vec3 vTransformedNormal;
  varying vec4 vPosition;

  void main(void) {
    vPosition = uMVMatrix * vec4(aVertexPosition, 1.0);
    gl_Position = uPMatrix * vPosition;
    vTextureCoord = aTextureCoord;
    vTransformedNormal = uNMatrix * aVertexNormal;
  }
</script>

Нам осталось вычислить положение вершины после применения матрицы модель-вид и умножить нормаль на матрицу нормали, но сейчас мы просто передаем их через varying-переменные во фрагментный шейдер и используем их там.

Вот и вся работа с шейдерами! Остальной код будет довольно знакомым из предыдущих уроков, но есть одно исключение. Раньше мы использовали только один вершинный шейдер и один фрагментный шейдер на странице WebGL. Текущий урок содержит две пары — одну для вершинного освещения, вторую — для попиксельного освещения. Теперь, как вы можете помнить из первого урока, объект программы WebGL, который мы использовали для передачи нашего кода шейдера в видеокарту, может содержать только один фрагментный шейдер и один вершинный шейдер. Это означает, что нам придется создавать две программы и переключаться между ними в зависимости от галочки «Per-fragment lighting».

Сделать это очень просто. Вот как теперь выглядит функция initShaders:


  var currentProgram;
  var perVertexProgram;
  var perFragmentProgram;
  function initShaders() {
    perVertexProgram = createProgram("per-vertex-lighting-fs", "per-vertex-lighting-vs");
    perFragmentProgram = createProgram("per-fragment-lighting-fs", "per-fragment-lighting-vs");
  }

Итак, у нас есть две программы в различных глобальных переменных — одна для вершинного освещения и одна для попиксельного, — и еще переменную currentProgram, где мы запоминаем, какую программу мы используем в данный момент. Теперь в initShaders мы просто передаем параметр в createProgram, которую мы используем для создания программы, поэтому не буду ее лишний раз здесь дублировать.

Затем мы просто переключаемся на нужную программу прямо в начале функции drawScene:


  function drawScene() {
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    mat4.perspective(45, gl.viewportWidth / gl.viewportHeight, 0.1, 100.0, pMatrix);

    var perFragmentLighting = document.getElementById("per-fragment").checked;
    if (perFragmentLighting) {
      currentProgram = perFragmentProgram;
    } else {
      currentProgram = perVertexProgram;
    }
    gl.useProgram(currentProgram);

Нам нужно переключиться на нужную программу перед всеми другими действиями, потому что при выполнении кода отрисовки (например, установка uniforms-переменных или буферов) текущая программа уже должна быть установлена, иначе мы можем использовать не ту программу:


  function drawScene() {
    var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(currentProgram.useLightingUniform, lighting);

Это означает, что для каждого вызова drawScene мы можем использовать только одну программу. Она меняется только между вызовами. Если вам интересно узнать, можете ли вы использовать разные программы в отдельных частях функции drawScene, чтобы разные части сцены были отрисованы разными программами (например, если одна часть сцены использует вершинное освещение, а другая — попиксельное), то ответом будет да, можете! Нам просто не нужно было этого в текущем уроке, но это вполне правильно и может быть очень полезным.

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

Урок 14 >> << Урок 12

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

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