WebGL Урок 11 — Сферы, матрицы поворотов и события мыши

Урок 12 >> << Урок 10

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

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

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

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

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

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

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

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

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


  function webGLStart() {
    canvas = document.getElementById("lesson11-canvas");
    initGL(canvas);
    initShaders();
    initBuffers();
    initTexture();

    gl.clearColor(0.0, 0.0, 0.0, 1.0);
    gl.enable(gl.DEPTH_TEST);

    canvas.onmousedown = handleMouseDown;
    document.onmouseup = handleMouseUp;
    document.onmousemove = handleMouseMove;

    tick();
  }

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

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


  function tick() {
    requestAnimFrame(tick);
    drawScene();
  }

Следующие существенные изменения происходят в drawScene. Функция начинается с обычного кода по очистке canvas и установке перспективы, затем настраивает освещение, как и в седьмом уроке:


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

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

Далее перемещаемся в нужные для отрисовки Луны координаты:


    mat4.identity(mvMatrix);

    mat4.translate(mvMatrix, [0, 0, -6]);

…и здесь встречается первый отрывок кода, который может показаться странным! По некоторым причинам, которые я объясню позже, мы храним текущий поворот Луны в матрице. Изначально матрица равна единичной матрице (то есть без поворота), а затем пользователь перетаскивает Луну мышью и матрица отражает эти манипуляции. Поэтому перед тем, как отрисовать Луну, нам нужно применить матрицу поворота к текущей матрице модель-вида, что мы и делаем с помощью функции mat4.multiply:


    mat4.multiply(mvMatrix, moonRotationMatrix);

После этого нам фактически остается отрисовать Луну. Этот код довольно стандартный — мы просто устанавливаем текстуру, а затем используем тот же код, который мы использовали множество раз до этого, чтобы WebGL использовала заранее подготовленные буферы для отрисовки треугольников:


    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, moonTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexPositionBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, moonVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0);

    gl.bindBuffer(gl.ARRAY_BUFFER, moonVertexTextureCoordBuffer);
    gl.vertexAttribPointer(shaderProgram.textureCoordAttribute, moonVertexTextureCoordBuffer.itemSize, gl.FLOAT, false, 0, 0);

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

    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, moonVertexIndexBuffer);
    setMatrixUniforms();
    gl.drawElements(gl.TRIANGLES, moonVertexIndexBuffer.numItems, gl.UNSIGNED_SHORT, 0);
  }

Итак, как же нам создать координаты вершин, нормали, текстурные координаты и буферы индексов с корректными для отрисовки сцены значениями? Этим занимается следующая по пути наверх функция initBuffers.

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


  var moonVertexPositionBuffer;
  var moonVertexNormalBuffer;
  var moonVertexTextureCoordBuffer;
  var moonVertexIndexBuffer;
  function initBuffers() {
    var latitudeBands = 30;
    var longitudeBands = 30;
    var radius = 2;

Итак, что же такое пояса широты и долготы? Чтобы отобразить набор треугольников, которые аппроксимируются на сферу, нам необходимо разделить их. Есть множество замысловатых подходов для этого и есть один простой, основанный на курсе геометрии средней школы, который (a) получает вполне приличный результат и (b) который я могу понять без ущерба для моей головы. Его и будем использовать :). Он основан на одном из примеров веб-сайта Khronos, который был изначально разработан командой WebKit и работает так:

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

Линии долготы не такие. Они делят сферу на сегменты. Если бы вы разрезали сферу по линиям долготы, это было бы похоже больше на апельсин.

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

Следующий вопрос в том, как вычислить точки пересечения линий широты и долготы? Представим, что сфера имеет радиус, равный единице. Начнем разрезать ее вертикально через центр в плоскости осей X и Y, как показано на изображении снизу. Очевидно, что разрез по своей форме будет кругом, а линии долготы будут линиями поперек этого круга. В этом примере вы можете видеть, что мы смотрим на третью линию пояса широты сверху, а всего этих поясов широты насчитывается десять. Угол между осью Y и точкой, где пояс широты достигает края окружности, равен θ. С применением небольшого объема тригонометрии мы получим, что Y-координата точки равна cos(θ), а X-координата равна sin(θ).

Теперь распространим эту формулу, чтобы вычислить подобные точки для всех линий широты. Так как мы хотим, чтобы каждая линия отстояла на одинаковую дистанцию по поверхности сферы от линии-соседа, мы просто может определить их через значение θ, которое равномерно распределено по окружности. В полукруге находится π радиан, поэтому для нашего примера из десяти линий мы можем взять значения θ равными 0, π/10, 2π/10, 3π/10 и так далее до 10π/10 — так мы будем уверены, что поделили сфену на равномерные пояса широты.

Все точки одной конкретной широты, независимо от значений долготы, имеют одинаковую координату Y. Поэтому, учитывая формулу выше для координаты Y, мы можем сказать, что все точки на n-ной широте сферы с радиусом равным единице и с десятью линиями широты будут иметь координату Y равной cos(nπ / 10).

Итак, с координатой Y понятно. Что насчет координаты X и Z? Так же, как координата Y равнялась cos(nπ / 10), координата X при Z равной нулю равняется sin(nπ / 10). Рассмотрим другой срез сферы, как показано на картинке слева — горизонтальный срез через плоскость n-ной широты. Мы видим, что все точки находятся на окружности с радиусом sin(nπ / 10). Назовем это значение k. Если мы разделим окружность по линиям долготы, количество которых предположим 10 и учитывая, что будет 2π радиан в окружности, то значениями угла φ, перемещающегося по окружности, будут 2π/10, 4π/10 и так далее. Несложной тригонометрией мы снова получим, что координаты X будут равняться kcos(φ), а координаты Z — ksin(φ).

Подводя итог, для сферы радиуса r с m поясами широты и n поясами долготы, мы можем определить значения x, y и z, разбивая диапазон от 0 до π на m частей значениями θ, и разбивая диапазон от 0 до 2π на n частей значениями φ, и затем вычисляя:

  • x = r sinθ cosφ
  • y = r cosθ
  • z = r sinθ sinφ

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

Определять координаты текстуры, возможно, даже легче. Мы ожидаем, что когда кто-то подготавливает текстуру для сферы, он сделает прямоугольное изображение (действительно, WebGL, если не сказать JavaScript, будет в замешательстве от чего-то другого!). Мы можем спокойно предположить, что эта текстура растягивается наверху и внизу согласно проекции Меркатора. Это значит, что мы можем определить координату текстуры u разделением текстуры слева направо линиями долготы, а координату v разделением сверху вниз линиями широты.

Вот так это все работает. Теперь код JavaScript должен быть совсем легким для понимания! Мы просто идем циклом через все срезы по широте, затем внутри этого цикла мы проходим все сегменты по долготе и генерируем нормали, текстурные координаты и координаты вершин для каждого. Единственная странность, которую нужно заметить, состоит в том, что циклы заканчиваются, когда индекс больше количества линий долготы/широты — то есть мы используем <= вместо < в условиях циклов. Это означает, что для 30 линий долготы мы сгенерируем 31 вершину по широте. Из-за цикличности тригонометрических функций последняя вершина будет находиться в тех же координатах, что и первая, что дает нам наложение вершины на вершину, в результате чего все склеивается.


    var vertexPositionData = [];
    var normalData = [];
    var textureCoordData = [];
    for (var latNumber = 0; latNumber <= latitudeBands; latNumber++) {
      var theta = latNumber * Math.PI / latitudeBands;
      var sinTheta = Math.sin(theta);
      var cosTheta = Math.cos(theta);

      for (var longNumber = 0; longNumber <= longitudeBands; longNumber++) {
        var phi = longNumber * 2 * Math.PI / longitudeBands;
        var sinPhi = Math.sin(phi);
        var cosPhi = Math.cos(phi);

        var x = cosPhi * sinTheta;
        var y = cosTheta;
        var z = sinPhi * sinTheta;
        var u = 1 - (longNumber / longitudeBands);
        var v = 1 - (latNumber / latitudeBands);

        normalData.push(x);
        normalData.push(y);
        normalData.push(z);
        textureCoordData.push(u);
        textureCoordData.push(v);
        vertexPositionData.push(radius * x);
        vertexPositionData.push(radius * y);
        vertexPositionData.push(radius * z);
      }
    }

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


    var indexData = [];
    for (var latNumber = 0; latNumber < latitudeBands; latNumber++) {
      for (var longNumber = 0; longNumber < longitudeBands; longNumber++) {
        var first = (latNumber * (longitudeBands + 1)) + longNumber;
        var second = first + longitudeBands + 1;
        indexData.push(first);
        indexData.push(second);
        indexData.push(first + 1);

        indexData.push(second);
        indexData.push(second + 1);
        indexData.push(first + 1);
      }
    }

Понять его довольно просто. Мы проходим циклом по всем вершинам, сохраняя ее индекс в переменной first, затем находим эквивалентную точку на нижнем поясе широты, прибавляя к переменной first longitudeBands + 1 индексов (один дополнительный из-за наложения вершин) и сохраняем это значение в переменной second. Затем мы создаем два треугольника, как показано на рисунке.

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

Сразу над функцией initBuffers расположились три функции, которые взаимодействуют с мышью. Они заслуживают особого внимания... Начнем с обсуждения того, чего мы вообще хотим добиться. А хотим мы, чтобы пользователь имел возможность вращать Луну с помощью перетягивания мышью. В простой реализации мы могли бы иметь, скажем, три переменных, которые представляли бы собой поворот вокруг осей X, Y и Z. Затем мы могли бы устанавливать каждую из них при перетягивании мышью. Если, скажем, пользователь тянет Луну вниз или вверх, мы бы изменяли значение поворота вокруг оси X, а при перетягивании в какую-либо сторону изменялось бы значение поворота по Y. Проблема в таком подходе заключается в том, что при вращении объекта вокруг различных осей имеет значение, в каком порядке вы будете изменять значения. Например, пользователь поворачивает Луну на 90° вокруг оси Y, а затем тянет мышью вниз. Если мы будем изменять вращение по оси X, как планируется в этом случае, Луна на самом деле будет вращаться вокруг оси Z. Первое вращение изменяет и направление осей. Для пользователя это будет выглядеть странно. Проблема усугубляется еще больше, когда пользователь, к примеру, поворачивает Луну на 10° вокруг оси X, затем на 23° вокруг уже повернутой оси Y, затем... мы могли бы применить кучу продвинутой логики для контроля над ситуацией вроде "в данном состоянии поворота, если пользователь тянет мышью вперед, мы обновляем все три значения поворота соответственно". Но более легким путем по управлению поворотами было бы вести что-то вроде записи каждого поворота Луны, который выполнил пользователь, а затем воспроизводить их каждый раз при отрисовке. На первый взгляд может показаться, что это сложный подход, но вспомните, что у нас уже есть отличный инструмент сохранения истории различных геометрических преобразований, название которому - матрица.

Мы заводим матрицу, которая будет хранить текущее состояние поворота Луны, которая имеет достаточно логичное название moonRotationMatrix. Когда пользователь тянет мышью, мы получаем череду событий перемещения мыши, и каждый раз при перемещении мы вычисляем, на сколько градусов вокруг текущих X и Y, как их видит пользователь, изменился поворот. Затем мы вычисляем матрицу, которая представляет эти два поворота и умножаем в обратном порядке moonRotationMatrix на нее - обратный порядок нужен по той же причине, почему мы применяем преобразования в обратном порядке, когда позиционируем камеру. Поворот происходит с точки зрения камеры, а не с точки зрения пространства модели. (Примечание для читателей - я уверен, что есть более хороший способ объяснения, но я не хотел откладывать выход урока до момента моего просветления :). Приму с благодарностью любые предложения по более удачной формулировке!)

Теперь, после объяснения, код должен быть довольно понятным:


  var mouseDown = false;
  var lastMouseX = null;
  var lastMouseY = null;

  var moonRotationMatrix = mat4.create();
  mat4.identity(moonRotationMatrix);

  function handleMouseDown(event) {
    mouseDown = true;
    lastMouseX = event.clientX;
    lastMouseY = event.clientY;
  }

  function handleMouseUp(event) {
    mouseDown = false;
  }

  function handleMouseMove(event) {
    if (!mouseDown) {
      return;
    }
    var newX = event.clientX;
    var newY = event.clientY;

    var deltaX = newX - lastMouseX;
    var newRotationMatrix = mat4.create();
    mat4.identity(newRotationMatrix);
    mat4.rotate(newRotationMatrix, degToRad(deltaX / 10), [0, 1, 0]);

    var deltaY = newY - lastMouseY;
    mat4.rotate(newRotationMatrix, degToRad(deltaY / 10), [1, 0, 0]);

    mat4.multiply(newRotationMatrix, moonRotationMatrix, moonRotationMatrix);

    lastMouseX = newX
    lastMouseY = newY;
  }

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

И на этом все! Теперь вы знаете, как отрисовать сферу, используя простой, но эффективный алгоритм; как обрабатывать события мыши, чтобы пользователи могли манипулировать 3D-объектами; и как использовать матрицы для хранения текущего состояния поворота объекта сцены.

Следующий урок продемонстрирует новый тип освещения - точечное освещение, которое исходит из определенного места сцены и расходится лучами, прямо как свет от всем знакомой лампочки.

Урок 12 >> << Урок 10

2 thoughts on “WebGL Урок 11 — Сферы, матрицы поворотов и события мыши

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

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