WebGL Урок 10 — Загрузка мира и основы камеры

Урок 11 >> << Урок 9

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

Добро пожаловать на мой десятый урок по WebGL, основанный на Урок 10 учебника NeHe по OpenGL. В нем мы загрузим 3D-сцену из файла (что означает, что мы можем легко подключить другой файл и расширить сцену) и добавим немного кода для возможности перемещаться по сцене — что-то вроде нано-Дума, со своим собственным форматом файлов WAD

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

Здесь можно посмотреть онлайн-демонстрацию, если ваш браузер поддерживает WebGL. Здесь можно узнать, что делать, если браузер не поддерживает WebGL. Вы окажетесь в комнате, где стены сделаны из фотографий Lionel Brits, кто написал уроки по OpenGL, на которых основаны эти уроки по WebGL :). Вы можете бегать по комнате и выбегать из нее, используя клавиши WASD, а также смотреть вниз и вверх с помощью клавиш Page Up и Page Down. Обратите внимание, что точка обзора ходит верх и вниз при беге, что сделано для большего реализма.

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

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

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

Вы можете посмотреть код этого примера двумя способами: посмотреть исходный код страницы с демонстрацией или, если вы используете GitHub, вы можете копировать урок (и другие уроки) из репозитория. Небольшое предупреждение — так как этот урок грузит детализацию сцены из отдельного файла, он не будет работать в браузере Chrome при загрузке файла с локальной машины. Это «особенность» безопасности Chrome. Поэтому для просмотра этой версии на вашей локальной машине используйте либо бета-версию Firefox, либо Minefield. Или установите веб-сервер у вас на компьютере и просматривайте страницу через него.

Как и в последнем уроке, проще всего начать с конца файла и продвигаться вверх. Давайте начнем с кода HTML, который находится внутри тега body в низу страницы, в котором (впервые с первого урока!) появилось кое-что интересное


<body onload="webGLStart();">
<a href="http://learningwebgl.com/blog/?p=1067"><< Back to Lesson 10</a><br />

  <canvas id="lesson10-canvas" style="border: none;" width="500" height="500"></canvas>

  <div id="loadingtext">Loading world...</div>

  <br/>
Use the cursor keys or WASD to run around, and <code>Page Up</code>/<code>Page Down</code> to
look up and down.

<br/>
<br/>
<a href="http://learningwebgl.com/blog/?p=1067"><< Back to Lesson 10</a>

</body>

Итак, у нас есть элемент DIV, содержащий текст, который нужно отобразить, пока идет загрузка мира. Если соединение между моим сервером и вашим компьютером было медленным при загрузке демонстрации, вы могли его заметить. Однако, сообщение появляется над элементом canvas, а не под ним, как вы могли ожидать от HTML. Такое перемещение стало возможным благодаря небольшому коду CSS, расположенному в конце элемента head:


<style type="text/css">
    #loadingtext {
        position:absolute;
        top:250px;
        left:150px;
        font-size:2em;
        color: white;
    }
</style>

С HTML закончили, пора переходить к JavaScript.

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


  function webGLStart() {
    var canvas = document.getElementById("lesson10-canvas");
    initGL(canvas);
    initShaders();
    initTexture();
    loadWorld();

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

    document.onkeydown = handleKeyDown;
    document.onkeyup = handleKeyUp;

    tick();
  }

Рассмотрим эту новую функцию. loadWorld находится сразу над drawScene, где-то в конце третьей четверти содержимого файла. И вот как она выглядит:


  function loadWorld() {
    var request = new XMLHttpRequest();
    request.open("GET", "world.txt");
    request.onreadystatechange = function() {
      if (request.readyState == 4) {
        handleLoadedWorld(request.responseText);
      }
    }
    request.send();
  }

Стиль кода может показаться знакомым. Он очень схож с кодом, где мы загружали текстуры. Мы создаем объект XMLHttpRequest, который будет управлять всей загрузкой, и говорим ему использовать GET-запрос для получения файла world.txt из той же директории на том же сервере, где находится текущая страница. Мы указываем функцию обратного вызова, которая вызывается на разных этапах загрузки. Она, в свою очередь, вызывает функцию handleLoadedWorld, когда XMLHttpRequest сообщит, что readyState равен 4, что случится при полной загрузке файла. После этих настроек мы говорим, чтобы XMLHttpRequest запускал процесс получения файла через вызов метода send.

Итак, перейдем к функции handleLoadedWorld, которая находится сразу над loadWorld:


  var worldVertexPositionBuffer = null;
  var worldVertexTextureCoordBuffer = null;
  function handleLoadedWorld(data) {

Задача функции — распарсить содержимое загруженного файла и использовать его для создания буферов двух типов, которые мы так часто видели в предыдущих уроках. Содержимое загруженного файла передается строковым параметром под названием data и первая часть кода просто парсит его. Формат используемого для нашего примера файла очень прост. Он содержит список треугольников, каждый из которых определен тремя вершинами. Каждая вершина занимает строчку файла и содержит следующие значения: координаты X, Y и Z и координаты текстуры S и T. Файл также содержит комментарии (линии, начинающиеся с //) и пустые строки, все это игнорируется. Есть еще строка вверху, которая указывает общее число треугольников (хотя мы ее и не используем).

Что ж, так ли ужасно хорош этот формат файла? Вообще-то, нет, он достаточно ужасен! В нем нет многих вещей, которые нам нужны для реальной сцены. Например, нормали или разные текстуры для разных объектов. В реальном примере вы будете использовать другой формат или даже JSON. Однако, я придерживаюсь этого формата, потому что он (a) используется в оригинальном уроке по OpenGL и (b) он простой и удобный для парсинга. Тем не менее, после всего сказанного, я не буду объяснять парсинг подробно. Вот этот код:


    var lines = data.split("\n");
    var vertexCount = 0;
    var vertexPositions = [];
    var vertexTextureCoords = [];
    for (var i in lines) {
      var vals = lines[i].replace(/^\s+/, "").split(/\s+/);
      if (vals.length == 5 && vals[0] != "//") {
        // It is a line describing a vertex; get X, Y and Z first
        vertexPositions.push(parseFloat(vals[0]));
        vertexPositions.push(parseFloat(vals[1]));
        vertexPositions.push(parseFloat(vals[2]));

        // And then the texture coords
        vertexTextureCoords.push(parseFloat(vals[3]));
        vertexTextureCoords.push(parseFloat(vals[4]));

        vertexCount += 1;
      }
    }

В итоге, все, что делает эта функция — принимает все линии из пяти значений, предполагая, что они содержат вершины, и затем заполняет ими массивы vertexPositions и vertexTextureCoords. Она также записывает количество вершин в переменную vertexCount.

Следующий отрывок кода должен быть очень знакомым на вид:


    worldVertexPositionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexPositionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexPositions), gl.STATIC_DRAW);
    worldVertexPositionBuffer.itemSize = 3;
    worldVertexPositionBuffer.numItems = vertexCount;

    worldVertexTextureCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, worldVertexTextureCoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexTextureCoords), gl.STATIC_DRAW);
    worldVertexTextureCoordBuffer.itemSize = 2;
    worldVertexTextureCoordBuffer.numItems = vertexCount;

Мы создали два буфера с загруженными подробностями о вершинах. Теперь, когда все готово, мы очищаем DIV, который отображал надпись «Загрузка мира…» (Loading World…)


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

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


// Floor 1
-3.0  0.0 -3.0 0.0 6.0
-3.0  0.0  3.0 0.0 0.0
 3.0  0.0  3.0 6.0 0.0

Вспомните, что это X, Y, Z, S, T, где S и T — координаты текстуры. Вы могли заметить, что значения координат текстуры находятся между 0 и 6. Но прежде я говорил, что координаты текстуры находятся в диапазоне между 0 и 1. Что же здесь происходит? Ответ состоит в том, что когда вы запрашиваете точку в текстуре, то для координат S и T автоматически находится остаток от деления на единицу, поэтому 5.5 находится в той же точке текстуры, что и 0.5. Это означает, что текстура автоматически повторяется плиткой столько раз, сколько это необходимо для заполнения треугольника. Очевидно, что это очень удобно, когда у вас есть маленькая текстура, которую вы хотите применить на большом объекте — скажем, фрагмент кирпичной кладки для покрытия всей стены.

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


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

    if (worldVertexTextureCoordBuffer == null || worldVertexPositionBuffer == null) {
      return;
    }

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


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

    mat4.identity(mvMatrix);

Пришло время приступить к работе с камерой — то есть позволять нам перемещаться по сцене. Первое, что вам необходимо помнить, — WebGL не поддерживает камеру «из коробки», но ее имитация — достаточно простая штука. Что касается камеры для этого простого примера, мы бы хотели уметь располагать ее в определенных координатах X, Y, Z, уметь задавать наклон вокруг оси X для возможности смотреть вниз или вверх (называемый тангаж) и поворот вокруг оси Y для поворота налево или направо (называемый рыскание). Из-за того, что мы не можем менять положение камеры, которая всегда находится в координатах (0, 0, 0) и направлена прямо по оси Z, мы бы хотели как-то указать WebG подогнать сцену, которая определена в координатах X, Y, Z (мировое пространство), в новую точку зрения, основанную на положении и повороте камеры (пространство камеры).

Здесь может помочь небольшой пример. Представим очень простую сцену, в которой находится куб с центром в (1, 2, 3) в мировых координатах. И больше ничего. Мы хотим в координатах (0, 0, 7) смоделировать камеру, направленную по оси Z, без тангажа и рыскания. Для этого мы преобразуем мировые координаты, и координаты камеры для центра куба примут значения (1, 2, -4). Повороты выполняются более сложно, но ненамного.

Наверное, довольно понятно, что это работа для матриц. И мы могли бы использовать «матрицу камеры», которая представляла бы положение и поворот камеры. Но для этого примера мы можем все сделать еще проще. Мы просто будем использовать существующую матрицу модель-вид.

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

Если взглянуть на это с точки зрения математики, для моделирования камеры в координатах (x, y, z), повернутой на ψ градусов рыскания и на θ градусов тангажа, мы сначала поворачиваем на -θ градусов вокруг оси X, затем на -ψ градусов вокруг оси Y, а затем перемещаемся в (-x, -y, -z). После этого матрица находится в режиме, когда все объекты могут использовать мировые координаты, и они автоматически преобразовываются в координаты камеры с помощью магии умножения матриц в вершинном шейдере.

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


    mat4.rotate(mvMatrix, degToRad(-pitch), [1, 0, 0]);
    mat4.rotate(mvMatrix, degToRad(-yaw), [0, 1, 0]);
    mat4.translate(mvMatrix, [-xPos, -yPos, -zPos]);

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


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

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

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

    setMatrixUniforms();
    gl.drawArrays(gl.TRIANGLES, 0, worldVertexPositionBuffer.numItems);
  }

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

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


  var pitch = 0;
  var pitchRate = 0;

  var yaw = 0;
  var yawRate = 0;

  var xPos = 0;
  var yPos = 0.4;
  var zPos = 0;

  var speed = 0;

  function handleKeys() {
    if (currentlyPressedKeys[33]) {
      // Page Up
      pitchRate = 0.1;
    } else if (currentlyPressedKeys[34]) {
      // Page Down
      pitchRate = -0.1;
    } else {
      pitchRate = 0;
    }

    if (currentlyPressedKeys[37] || currentlyPressedKeys[65]) {
      // Left cursor key or A
      yawRate = 0.1;
    } else if (currentlyPressedKeys[39] || currentlyPressedKeys[68]) {
      // Right cursor key or D
      yawRate = -0.1;
    } else {
      yawRate = 0;
    }

    if (currentlyPressedKeys[38] || currentlyPressedKeys[87]) {
      // Up cursor key or W
      speed = 0.003;
    } else if (currentlyPressedKeys[40] || currentlyPressedKeys[83]) {
      // Down cursor key
      speed = -0.003;
    } else {
      speed = 0;
    }

  }

В примере выше при нажатии на левую кнопку мыши переменная yawRate устанавливается в 0.1°/мс или 100°/s — другими словами, мы начинаем вращаться влево с частотой один оборот в 3.6 секунды.

Эти значения скорости изменения, как вы можете ожидать из предыдущих уроков, используются в функции animate для установки значений xPos и zPos, yaw и pitch. yPos также устанавливается в animate, но немного другой логикой. Давайте взглянем на все это. Код находится сразу под drawScene, ближе к концу файла. Вот первые несколько строк:


  var lastTime = 0;
  // Used to make us "jog" up and down as we move forward.
  var joggingAngle = 0;
  function animate() {
    var timeNow = new Date().getTime();
    if (lastTime != 0) {
      var elapsed = timeNow - lastTime;

Большая часть из этого — наш привычный код для вычисления количества миллисекунд, прошедших с момента последнего вызова функции animate. joggingAngle более интересный. Мы создаем эффект подпрыгивания, изменяя координату Y по волнам синусоиды с серединой в районе «головы», кроме моментов, когда мы стоим на месте. joggingAngle — это угол, который мы передаем функции синуса для определения нашего текущего положения.

Взглянем на код, который все это делает, и кроме того устанавливает x и z для возможности перемещения.


      if (speed != 0) {
        xPos -= Math.sin(degToRad(yaw)) * speed * elapsed;
        zPos -= Math.cos(degToRad(yaw)) * speed * elapsed;

        joggingAngle += elapsed * 0.6;  // 0.6 "fiddle factor" -- makes it feel more realistic :-)
        yPos = Math.sin(degToRad(joggingAngle)) / 20 + 0.4
      }

Очевидно, что изменения положения и эффект подпрыгивания имеет место только тогда, когда мы действительно движемся. Поэтому если значение speed не равно нулю, значения xPos и zPos задаются текущей скоростью, используя несложную тригонометрию (с помощью функции degToRad, которую мы использовали для нашего удобства, для перевода из градусов в радианы, которые принимает тригонометрическая функция JavaScript). Далее мы вычисляем нашу текущую yPos на основании joggingAngle. Все используемые нами числа мы умножаем на количество миллисекунд, прошедших с момента последнего вызова, что совершенно разумно в случае скорости — speed, которая измеряется в единицах в миллисекунду. Значение 0.6, с помощью которого мы вычисляем прошедшее количество миллисекунд для joggingAngle, подобрано просто методом проб и ошибок для создания реалистичного эффекта.

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


      yaw += yawRate * elapsed;
      pitch += pitchRate * elapsed;

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


    }
    lastTime = timeNow;
  }

Готово! Теперь вы знаете, как загрузить сцену из файла и смоделировать камеру.

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

Урок 11 >> << Урок 9

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

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