WebGL Урок 14 — Блики и загрузка JSON-модели

Урок 15 >> << Урок 13

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

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

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

Здесь можно посмотреть онлайн-демонстрацию, если ваш браузер поддерживает WebGL. Здесь можно узнать, что делать, если браузер не поддерживает WebGL. Вы увидите вращающийся чайник с бликами на левой части корпуса и на ручке крышки. Также блики будут иногда появляться на носике и ручке, когда они будут находиться под подходящим углом к свету. Блики можно включать и отключать через соответствующую галочку, кроме того можно полностью отключить освещение и выбирать одну из трех доступных текстур: Отсутствует, Оцинкованный, которая используется по умолчанию (и которая является экземпляром под лицензией Creative Commons из замечательной коллекции Arroway Textures) и (просто ради забавы) текстура планеты Земля (любезно предоставленной Европейским космическим агентством), которая смотрится по-странному привлекательной на чайнике :).

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

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

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

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

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

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

Итак, первым вы увидите фрагментный шейдер для попиксельного освещения. Он начинается с обычных объявлений varying-переменных и uniform-переменных, где появляются несколько новых объявлений (выделены красным) и одна — которая содержала цвет точечного освещения — была переименована, так как точечное освещение теперь имеет отражательный и рассеивающий компоненты.


  precision mediump float;

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

  uniform float uMaterialShininess;

  uniform bool uShowSpecularHighlights;
  uniform bool uUseLighting;
  uniform bool uUseTextures;

  uniform vec3 uAmbientColor;

  uniform vec3 uPointLightingLocation;
  uniform vec3 uPointLightingSpecularColor;
  uniform vec3 uPointLightingDiffuseColor;

  uniform sampler2D uSampler;

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


  void main(void) {
    vec3 lightWeighting;
    if (!uUseLighting) {
      lightWeighting = vec3(1.0, 1.0, 1.0);
    } else {

Теперь обрабатывается освещение, и здесь, конечно же, становится интересно:


      vec3 lightDirection = normalize(uPointLightingLocation - vPosition.xyz);
      vec3 normal = normalize(vTransformedNormal);

      float specularLightWeighting = 0.0;
      if (uShowSpecularHighlights) {

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

Что же определяет яркость бликов? Как вы можете помнить из объяснения модели затенения Фонга из седьмого урока, блики получаются в результате отражения пучка света от поверхности, как от зеркала:

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

Вот так выглядит уравнение для вычисления отраженного света:

  • (Rm . V)α

…где Rm — это (нормализованный) вектор идеально отраженного луча света, который падает на рассматриваемую точку поверхности, V — это (тоже нормализованный) вектор направления взгляда пользователя, и, наконец, α — это константа, определяющая блеск — чем выше, тем ярче. Возможно, вы помните, что скалярное произведение двух векторов равно косинусу угла между ними. Это означает, что данная часть уравнения равна единице, если свет из источника будет отражен прямо на пользователя (то есть Rm and V параллельны и поэтому угол между ними равен нулю, а косинус нуля равен единице) и затем свет будет становиться понемногу тусклее, когда угол падения луча будет отклоняться от пользователя. Возводя это значение в степень α, мы получим эффект «сжатия» — то есть при результате по-прежнему равном единице, когда вектора параллельны, он уменьшается быстрее в любую из сторон. Вы можете увидеть это в действии, если установите значение константы блеска на странице онлайн-демонстрации во что-то большое — например, 512.

Принимая во внимание все вышеперечисленное, первым делом нам нужно рассчитать направление взгляда пользователя — V — и направление идеально отраженного луча света Rm. Взглянем сначала на V, потому что это проще! Наша сцена находится в пространстве камеры, которую вы можете вспомнить из десятого урока. В сущности, это означает, что отрисовка сцены происходит, как-будто в координатах (0, 0, 0) находится камера, которая направлена в сторону убывания по оси Z, с осью X, увеличивающейся вправо, и Y, увеличивающейся вверх. Направление к любой точке из нулевых координат — это просто ее координаты, выраженные вектором. Аналогично, направление взгляда пользователя от любой точки к нулевым координатам — просто отрицательные значения координат. Координаты фрагмента, линейно интерполированные из вершинных координат, хранятся в vPosition, поэтому мы просто меняем им знак, нормализуем вектор до единичной длины — и все!


        vec3 eyeDirection = normalize(-vPosition.xyz);

Теперь взглянем на Rm. Все было бы немного сложнее, если бы в GLSL не было функции reflect, которая определена следующим образом:

reflect (I, N): Для произвольного вектора I и направления поверхности N возвращает противоположное направление

Произвольным вектором в нашем случае будет направление, под которым луч света падает на фрагмент поверхности (которое у нас уже есть в виде lightDirection). Направление поверхности названо N, потому что это просто нормаль, которая у нас также уже есть. Теперь нам просто все рассчитать:


        vec3 reflectionDirection = reflect(-lightDirection, normal);

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


        specularLightWeighting = pow(max(dot(reflectionDirection, eyeDirection), 0.0), uMaterialShininess);
      }

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


      float diffuseLightWeighting = max(dot(normal, lightDirection), 0.0);

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


      lightWeighting = uAmbientColor
        + uPointLightingSpecularColor * specularLightWeighting
        + uPointLightingDiffuseColor * diffuseLightWeighting;
    }

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


    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);
  }

Вот и весь фрагментный шейдер!

Теперь продвинемся немного ниже. Если вы будете искать отличия от тринадцатого урока, то заметите, что функция initShaders вернулась в ее раннюю простую форму, где создается лишь одна программа, хотя она дополнительно инициализирует одну-две uniform-переменных для настройки бликов. Еще дальше по направлению вниз идет initTextures, которая теперь загружает текстуры Земли и оцинкованной стали вместо Луны и ящика. А еще ниже setMatrixUniforms, где, как и в initShaders, мы вернулись к одной программе. И теперь мы подошли к чему-то более интересному.

Вместо initBuffers для создания буферов WebGL с различными вершинными атрибутами, определяющими внешний вид чайника, у нас появилось две функции: handleLoadedTeapot и loadTeapot. Подход вам уже знаком из десятого урока, но стоит еще раз его осветить. Взглянем на loadTeapot (хотя она и появляется по коду второй):


  function loadTeapot() {
    var request = new XMLHttpRequest();
    request.open("GET", "Teapot.json");
    request.onreadystatechange = function() {
      if (request.readyState == 4) {
        handleLoadedTeapot(JSON.parse(request.responseText));
      }
    }
    request.send();
  }

Общий вид должен быть вам знаком из десятого урока. Мы создаем новый объект XMLHttpRequest и используем его, чтобы загрузить файл Teapot.json. Этот процесс выполняется асинхронно, поэтому нам понадобится функция обратного вызова, которая будет срабатывать на разных этапах загрузки файла. При readyState равном 4, что означает окончание загрузки, начинает свою работу функция обратного вызова.

Теперь переходим к интересной части. Файл, который мы загрузили, имеет формат JSON, что означает, что он, по существу, уже написан на JavaScript. Взгляните на него, чтобы понять, о чем я говорю. Файл описывает объект JavaScript, содержащий массивы координат вершин, нормалей, текстурных координат и набора индексов вершин, которые полностью описывают чайник. Разумеется, мы могли бы хранить этот код прямо в index.html, но при построении более сложных моделей с множеством отдельных объектов вы бы предпочли поместить их в отдельные файлы.

(Какой именно формат использовать для готовых объектов в вашем приложении WebGL — это интересный вопрос. Модели могут быть созданы в одной из многочисленных программ, и эти программы могут хранить модели в множестве различных форматов, начиная от .obj и заканчивая 3DS. Похоже, что в будущем кто-то из них научится выводить модели в формате, понятному JavaScript, который вполне может быть похож на тот формат, который я использовал для чайника. А пока рассматривайте этот урок как пример загрузки готовой модели в формате JSON, но не как лучший из подходов 🙂

Итак, у нас есть код, который загружает JSON-файл и вызывает колбэк при завершении загрузки. В колбэке происходит преобразование текста в формате JSON в даные, которые мы сможем использовать. Мы могли бы просто использовать JavaScript-функцию eval для преобразования нашего текста в объект JavaScript, но на это обычно смотрят неодобрительно, поэтому мы используем встроенную функцию JSON.parse для получения объекта. Далее мы передаем его в handleLoadedTeapot:


  var teapotVertexPositionBuffer;
  var teapotVertexNormalBuffer;
  var teapotVertexTextureCoordBuffer;
  var teapotVertexIndexBuffer;
  function handleLoadedTeapot(teapotData) {
    teapotVertexNormalBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexNormalBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexNormals), gl.STATIC_DRAW);
    teapotVertexNormalBuffer.itemSize = 3;
    teapotVertexNormalBuffer.numItems = teapotData.vertexNormals.length / 3;

    teapotVertexTextureCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexTextureCoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexTextureCoords), gl.STATIC_DRAW);
    teapotVertexTextureCoordBuffer.itemSize = 2;
    teapotVertexTextureCoordBuffer.numItems = teapotData.vertexTextureCoords.length / 2;

    teapotVertexPositionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, teapotVertexPositionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(teapotData.vertexPositions), gl.STATIC_DRAW);
    teapotVertexPositionBuffer.itemSize = 3;
    teapotVertexPositionBuffer.numItems = teapotData.vertexPositions.length / 3;

    teapotVertexIndexBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, teapotVertexIndexBuffer);
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(teapotData.indices), gl.STATIC_DRAW);
    teapotVertexIndexBuffer.itemSize = 1;
    teapotVertexIndexBuffer.numItems = teapotData.indices.length;

    document.getElementById("loadingtext").textContent = "";
  }

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

Итак, модель загружена. Что еще? Есть еще drawScene, которая отрисовывает чайник под заданным углом (после проверки, что он загружен), но ничего действительного нового там нет. Взгляните на код и убедитесь, что вы понимаете, что в нем происходит (и прошу вас оставлять комментарии, если что-то не понятно), но я сомневаюсь, что вас там что-то удивит.

Кроме того, animate содержит несколько незначительных изменений для вращения чайника вокруг своей оси вместо вращения Луны и ящика по орбите, а webGLStart вызывает loadTeapot вместо initBuffers. И, наконец, код HTML содержит DIV с соответствующими CSS-стилями, который отображает надпись «Loading world…» (загрузка мира), пока чайник грузится и еще новые поля ввода для параметров бликов.

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

Урок 15 >> << Урок 13

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

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