WebGL Урок 6 — Управление клавиатурой и фильтрация текстур

Урок 7 >> << Урок 5

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

Добро пожаловать на мой шeстой урок по WebGL, основанный на Урок 7 учебника NeHe по OpenGL. Здесь мы разберем, как можно управлять объектами сцены с помощью клавиатуры, и мы применим эти знания для изменения частоты и направления вращения текстурированного куба. Кроме того, мы сможем менять фильтрации текстуры, чтобы получить текстуры низкого качества с быстрым рендерингом или текстуры высокого качества, но с более медленным рендерингом. (Седьмой урок NeHe покрывает еще освещение. Но из-за того, что освещение в WebGL устроено сложнее, нежели в OpenGL, я оставил его для следующего урока)

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

Здесь можно посмотреть онлайн-демонстрацию, если ваш браузер поддерживает WebGL. Здесь можно узнать, что делать, если браузер не поддерживает WebGL. Когда вы откроете страницу, используйте клавиши Page Up и Page Down для приближения и отдаления, а стрелки для придания вращения кубу (чем больше держать стрелку, тем быстрее он будет крутиться). Также вы можете использовать клавишу F для переключения между тремя разными фильтрами текстур. Эффект от переключения будет хорошо заметен, когда вы достаточно близко к кубу и когда вы достаточно далеко. О том, что именно происходит, мы поговорим позже.

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

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

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

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

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


  var xRot = 0;
  var xSpeed = 0;

  var yRot = 0;
  var ySpeed = 0;

  var z = -5.0;

  var filter = 0;

xRot и yRot уже знакомы вам из пятого урока — они представляют текущее значение вращения куба по X и Y. xSpeed и ySpeed должны быть достаточно понятными. Теперь мы позволяем пользователю управлять скоростью вращения куба, используя стрелки, а текущую скорость вращения храним в xSpeed и ySpeed. z, конечно же, — это Z-координата куба, то есть как близко находится куб к наблюдателю. Изменять ее можно через клавиши Page Up и Page Down. И, наконец, filter — это целое число от 0 до 2, которое указывает один из трех фильтров текстуры куба и, соответственно, как хорошо куб будет выглядеть при разных значениях.

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


  function handleLoadedTexture(textures) {
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

    gl.bindTexture(gl.TEXTURE_2D, textures[0]);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[0].image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

    gl.bindTexture(gl.TEXTURE_2D, textures[1]);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[1].image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

    gl.bindTexture(gl.TEXTURE_2D, textures[2]);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textures[2].image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
    gl.generateMipmap(gl.TEXTURE_2D);

    gl.bindTexture(gl.TEXTURE_2D, null);
  }

  var crateTextures = Array();

  function initTexture() {
    var crateImage = new Image();

    for (var i=0; i < 3; i++) {
      var texture = gl.createTexture();
      texture.image = crateImage;
      crateTextures.push(texture);
    }

    crateImage.onload = function() {
      handleLoadedTexture(crateTextures)
    }
    crateImage.src = "crate.gif";
  }

С первого взгляда на функцию initTexture и глобальную переменную crateTextures должно быть понятно, что код изменился. В основе изменилось лишь то, что теперь мы создаем массив из трех объектов текстур вместо одного, который передается в handleLoadedTexture, когда изображение окончательно загрузится. И, конечно, мы грузим другое изображение - crate.gif вместо nehe.gif.

В функции handleLoadedTexture тоже ничего сложного не произошло. Раньше мы инициализировали один объект текстуры, передавая в него параметрами gl.TEXTURE_MAG_FILTER и gl.TEXTURE_MIN_FILTER с одинаковым значением gl.NEAREST. Сейчас мы инициализируем три текстуры из массива - все с одинаковым изображением, но с разными параметрами каждый. Кроме того, для последней мы дописали немного дополнительного кода. Рассмотрим, чем же отличаются различные текстуры.

Ближайшая точка

В первой текстуре параметры gl.TEXTURE_MAG_FILTER и gl.TEXTURE_MIN_FILTER установлены в gl.NEAREST. Это означает, что когда текстура растягивается или сжимается, WebGL должна всегда определять цвет заданной точки просто поиском ближайшей соответствующей точки в исходном изображении. Текстура будет смотреться хорошо, если текстура не масштабируется совсем, и неплохо при сжатии картинки (однако, смотрите обсуждение алиасинга ниже). Однако, при растяжении она пойдет "квадратами", потому что алгоритм жертвует картинкой в пользу скорости.

Линейная фильтрация

Во второй текстуре параметры gl.TEXTURE_MAG_FILTER и gl.TEXTURE_MIN_FILTER установлены в gl.LINEAR. Снова текстура использует один фильтр и для растяжения, и для сжатия изображения. Однако, линейный алгоритм работает лучше при растяжении. В своей основе он использует простую линейную интерполяцию между пикселями исходного изображения текстуры - грубо говоря, пиксель между белым и черным станет серым. Это дает значительный сглаживающий эффект, хотя (естественно) резкие края тоже "смажутся". (Если быть честным, при приближении изображения оно никогда не будет выглядеть идеально - вы не можете получить детализации, которой там нет).

Мипмапы (Mipmaps)

Что касается третьей текстуры, в ней параметр gl.TEXTURE_MAG_FILTER равен gl.LINEAR, а параметр gl.TEXTURE_MIN_FILTER установлен в gl.LINEAR_MIPMAP_NEAREST. Это наиболее сложный вариант.

Линейная фильтрация дает приемлемый результат при приближении, но она не лучше нахождения ближайшей точки при отдалении. Оба этих фильтра имеют эффект алиасинга. Чтобы увидеть, что это такое, снова откройте пример, включите фильтр по ближайшей точке (или просто обновите страницу, чтобы вернуть исходные настройки) и зажмите клавишу Page Up на несколько секунд для отдаления. По мере удаления куба вы начнете замечать, что он начинает "рябить" - вертикальные линии будут то пропадать, то появляться вновь. Когда увидите это, попробуйте поприближать и поотдалять немного и понаблюдайте за эффектом. Затем нажмите на клавишу F для включения линейной фильтрации и убедитесь, что эффект никуда не делся. Теперь снова нажмите на F для использования мипмамов и снова поприближайтесь и поотдаляйтесь. Вы увидите, что эффект устранен или по крайней мере значительно снижен.

Теперь, когда куб достаточно далеко, - скажем, 10% от высоты/ширины элемента canvas - попробуйте попереключать фильтры, не двигая сам куб. При линейной фильтрации или фильтрации по ближайшей точке вы заметите, что в некоторых местах темные линии в местах стыка досок видны очень отчетливо, в то время как другие линии исчезли совсем. Куб словно бликует. В фильтрации по ближайшей точке все особенно плохо, но и в линейной ненамного лучше. Хорошо работают только мипмапы.

Дело в том, что линейная фильтрация и фильтрация по ближайшей точке при сжатии изображения в (скажем) десять раз берет каждый десятый пиксель исходного изображения для получения сжатой версии. Текстура имеет дощатый рисунок, то есть ее основная часть светло-коричневая, но имеются тонкие темные вертикальные полосы. Представим, что доска занимает десять пикселей в ширину, или другими словами через каждые девять светлых пикселей при движении по горизонтали идет один темно-коричневый пиксель. Если изображение сжимается в десять раз, вероятность темно-коричного пикселя составляет десять процентов, а вероятность светлого - девяносто. Или другими словами, одна из десяти темных линий исходного изображения будет видна так же отчетливо, как и в исходном изображении, в то время как остальные видно не будет вообще. Это и вызывает эффект бликов, а также добавляет мерцание при изменении масштаба, потому что на масштабах 9.9, 10.0 и 10.1 могут показываться или скрываться совершенно разные линии.

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

Мипмапы решают проблему генерацией нескольких вспомогательных изображений для текстуры (называемых MIP-уровнями), составляющих половину, четверть, восьмую часть от исходного изображения и так далее до версии один на один пиксель. Набор из всех этих уровней называется мипмап. Каждый MIP-уровень представляет собой равномерно усредненную версию следующего, более крупного уровня, поэтому подходящая версия может быть найдена для каждого масштаба. Алгоритм для поиска нужного уровня зависит от значения gl.TEXTURE_MIN_FILTER. Выбранное нами значение означает "найди ближайший MIP-уровень и выполни линейную фильтрацию для получения пикселя".

После моего объяснения должно быть достаточно понятно, что дополнительная линия для этой текстуры:


    gl.generateMipmap(gl.TEXTURE_2D);

...указывает WebGL, что нужно сформировать мипмап.

На объяснение мипмапов ушло гораздо больше, чем я планировал написать, но надеюсь, что теперь все достаточно понятно :). Дайте мне знать в комментариях, если я что-то упустил при объяснении.

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

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


    mat4.translate(mvMatrix, [0.0, 0.0, z]);

Во-вторых, мы больше не вращаем куб по всем осям, как это было в пятом уроке. Мы убрали вращение вокруг оси Z и используем вращение только вокруг X и Y:


    mat4.rotate(mvMatrix, degToRad(xRot), [1, 0, 0]);
    mat4.rotate(mvMatrix, degToRad(yRot), [0, 1, 0]);

И, наконец, мы указываем, какую из трех текстур мы будем использовать при отрисовке:


    gl.bindTexture(gl.TEXTURE_2D, crateTextures[filter]);

Это и все изменения в drawScene. Есть также небольшие изменения в animate. Вместо изменения значений xRot и yRot на постоянной скорости теперь мы используем переменные xSpeed и ySpeed:


      xRot += (xSpeed * elapsed) / 1000.0;
      yRot += (ySpeed * elapsed) / 1000.0;

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

Первое из них находится прямо внизу страницы, в webGLStart, где мы добавили две новых строки (выделены красным ниже):


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

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

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

    tick();
  }

Достаточно прозрачно, что здесь мы говорим среде выполнения JavaScript, что при нажатии клавиши (когда фокус на веб-странице) мы хотим, чтобы вызывалась наша функция handleKeyDown, а при отпускании клавиши вызывалась функция handleKeyUp.

Давайте взглянем на эти функции. Они находятся примерно в середине страницы, прямо под глобальными переменными, рассмотренными ранее, и выглядят так:


  var currentlyPressedKeys = {};

  function handleKeyDown(event) {
    currentlyPressedKeys[event.keyCode] = true;

    if (String.fromCharCode(event.keyCode) == "F") {
      filter += 1;
      if (filter == 3) {
        filter = 0;
      }
    }
  }

  function handleKeyUp(event) {
    currentlyPressedKeys[event.keyCode] = false;
  }

Здесь мы заполняем словарь (который может быть вам известен как хэш-таблица или ассоциативный массив), который хранит цифровые значения нажатых клавиш и может сказать, нажата ли та или иная клавиша пользователем или нет. Если вы не знакомы с работой JavaScript, вы можете найти интересным, что каждый объект может быть использован в качестве словаря похожим способом. Хотя синтаксис инициализации currentlyPressedKeys выглядит, как определение словаря в Python, это, однако, просто "пустой" экземпляр базового типа объекта.

Кроме заполнения словаря нажатыми клавишами мы также проверяем, не нажата ли клавиша "F". Если нажата, мы циклически присваиваем глобальной переменной filter значения 0, 1 и 2.

Стоит рассказать, почему мы обрабатываем разные клавиши двумя разными способами. В компьютерной игре, да и почти в любой подобной 3D-системе, нажатие на клавиши может обрабатываться двумя способами:

  1. Они могут незамедлительно вызывать действие - например, "стрельба из лазера". Подобные нажатия могут автоматически повторяться с какой-то фиксированной частотой - например, дважды в секунду.
  2. Они могут зависеть от того, как долго вы удерживаете клавишу нажатой. Например, если вы жмете клавишу для ходьбы вперед, вы ожидаете, что будете продолжать двигаться вперед, пока не отожмете клавишу.

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

Поэтому в коде, который мы только что рассматривали, клавиша "F" обрабатывается по первому сценарию. Словарь используется кодом, который работает по второму сценарию. Он запоминает все клавиши, которые сейчас нажаты, а не только последнюю.

Сам словарь используется в другой функции - handleKeys, которая идет следующей по коду. Прежде, чем мы разберем ее, перейдите ненадолго в низ страницы кода и посмотрите, что она вызывается в функции tick, как и drawScene вместе с animate:


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

Так выглядит функция handleKeys:


  function handleKeys() {
    if (currentlyPressedKeys[33]) {
      // Page Up
      z -= 0.05;
    }
    if (currentlyPressedKeys[34]) {
      // Page Down
      z += 0.05;
    }
    if (currentlyPressedKeys[37]) {
      // Left cursor key
      ySpeed -= 1;
    }
    if (currentlyPressedKeys[39]) {
      // Right cursor key
      ySpeed += 1;
    }
    if (currentlyPressedKeys[38]) {
      // Up cursor key
      xSpeed -= 1;
    }
    if (currentlyPressedKeys[40]) {
      // Down cursor key
      xSpeed += 1;
    }
  }

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

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

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

В следующем уроке мы пустим в ход освещение.

Урок 7 >> << Урок 5

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

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