WebGL Урок 9 — Улучшение структуры кода на примере множества двигающихся объектов

Урок 10 >> << Урок 8

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

Добро пожаловать на мой девятый урок по WebGL, основанный на Урок 9 учебника NeHe по OpenGL. В нем мы будем использовать объекты JavaScript для создания нескольких независимых анимированных объектов 3D-сцены. Мы также затронем тему изменения цвета загруженной текстуры, и что получится в результате смешивания текстур.

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

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

Под элементом canvas находятся флажок для включения эффекта мерцания (Twinkle), который мы вскоре рассмотрим. Еще можно использовать стелки для вращения анимации вокруг оси X, а также приближаться и отдаляться с помощью клавиш Page Up и Page Down.

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

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

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

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

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


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

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

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

    tick();
  }

Я отметил красным цветом единственное изменение — вызов новой функции initWorldObjects. Эта функция создает объекты JavaScript для изображения сцены. И прежде чем к ней перейти, сделаю важное замечание о другом изменении здесь. Все предыдущие функции webGLStart содержали строку для установки проверки глубины:


    gl.enable(gl.DEPTH_TEST);

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

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


  var lastTime = 0;
  function animate() {
    var timeNow = new Date().getTime();
    if (lastTime != 0) {
      var elapsed = timeNow - lastTime;

      for (var i in stars) {
        stars[i].animate(elapsed);
      }
    }
    lastTime = timeNow;

  }

Дальше вверх по пути идет 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);

Это просто обычная настройка, которая не изменилась с первого урока.


    gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
    gl.enable(gl.BLEND);

Далее мы включаем смешивание. Мы используем то же смешивание, что и в последнем уроке. Вы вспомните, что это позволяет объектам быть видимыми друг через друга. Кроме того, полезно, что черные участки объекта рисуются так, как если бы они были прозрачными. Чтобы увидеть, как это работает, взгляните на описание функции смешивания в прошлом уроке. Это означает, что при отрисовке звезд нашей сцены черные участки будут прозрачными. Действительно, чем менее яркой является часть звезды, тем более прозрачной она будет видна. И так как звезда рисуется с помощью текстуры:

…это дает нам как раз нужный эффект.

Следующий фрагмент кода:


    mat4.identity(mvMatrix);
    mat4.translate(mvMatrix, [0.0, 0.0, zoom]);
    mat4.rotate(mvMatrix, degToRad(tilt), [1.0, 0.0, 0.0]);

Здесь мы просто двигаемся в центр сцены и приближаемся на подходящее расстояние. Мы также наклоняем сцену вокруг оси X. zoom и tilt — по-прежнему глобальные переменные, зависящие от управления клавиатурой. Теперь мы достаточно подготовлены для отрисовки сцены, поэтому сначала проверяем, установлен ли флажок мерцания:


    var twinkle = document.getElementById("twinkle").checked;

…а затем, как и при анимации, мы идем в цикле по списку звезд и говорим каждой, чтобы она отрисовала себя. Мы передаем текущий наклон сцены и флаг мерцания. Кроме кого, мы передаем текущее значение вращения (spin) — то, с какой скоростью звезды вращаются вокруг их центра по своим орбитам.


    for (var i in stars) {
      stars[i].draw(tilt, spin, twinkle);
      spin += 0.1;
    }

  }

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


  var stars = [];
  function initWorldObjects() {
    var numStars = 50;

    for (var i=0; i < numStars; i++) {
      stars.push(new Star((i / numStars) * 5.0, i / numStars));
    }
  }

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

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

Объектная модель JavaScript сильно отличается от моделей других языков. Мне проще всего считать, что каждый объект создается как словарь (он же хэш-таблица, он же ассоциативный массив), а затем превращается в оперившийся объект через помещение в него значений. Поля объекта - это просто ключи словаря, которые ссылаются на значения, а методы - ключи, которые ссылаются на функции. Добавим еще факт, что для ключей без пробелов допустим синтаксис foo.bar, что равнозначно foo["bar"]. Такой синтаксис похож на синтаксис в других языках, но происходит совсем от иного начального пункта.

Далее, когда вы находитесь внутри функции JavaScript, вы можете обратиться к неявно заданной переменной this, которая ссылается на "владельца" функции. Для глобальных функций это глобальный объект текущей веб-страницы, но если вы поместите ключевое слово new перед функцией, то this будет ссылаться на совсем другой объект. Поэтому, если у вас есть функция, устанавливающая this.foo в значение 1 и this.bar ссылкой на функцию, и вы всегда вызываете ее с ключевым словом new, это на самом деле будет конструктор с определением класса!

Еще мы можем заметить, что если функция вызывается через синтаксис метода (то есть foo.bar()), то this будет ссылаться на владельца функции (foo), как мы и ожидали, поэтому методы объекта могут выполнять действия над самим объектом.

И, наконец, с функцией связан специальный атрибут prototype. Это словарь значений, которые связаны с каждым объектом, который создается через ключевое слово new над этой функцией. Это хороший способ хранить объекты, которые будут одинаковыми для каждого объекта "класса" - например, методы.

[Спасибо murphy и doug за комментарии и странице Sergio Pereira за помощь в корректировке моих объяснений.]

Взглянем на функцию, которая определяет объект Star для сцены.


  function Star(startingDistance, rotationSpeed) {
    this.angle = 0;
    this.dist = startingDistance;
    this.rotationSpeed = rotationSpeed;

    // Set the colors to a starting value.
    this.randomiseColors();
  }

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


  Star.prototype.draw = function(tilt, spin, twinkle) {
    mvPushMatrix();

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


    // Move to the star's position
    mat4.rotate(mvMatrix, degToRad(this.angle), [0.0, 1.0, 0.0]);
    mat4.translate(mvMatrix, [this.dist, 0.0, 0.0]);

Далее мы поворачиваемся вокруг оси Y на соответствующий звезде угол и отдаляемся на соответствующее расстояние от центра. Это помещает нас в нужную для отрисовки звезды позицию.


    // Rotate back so that the star is facing the viewer
    mat4.rotate(mvMatrix, degToRad(-this.angle), [0.0, 1.0, 0.0]);
    mat4.rotate(mvMatrix, degToRad(-tilt), [1.0, 0.0, 0.0]);

Эти линии нужны, чтобы при изменении наклона сцены с помощью стрелок клавиатуры звезды выглядели по-прежнему правильно. Они отображены как 2D-текстуры на квадрате, которые выглядят правильно, когда мы смотрим на них прямо, но отображались бы только линией, если бы мы наклонили сцену и смотрели бы на нее "с торца". По аналогичным причинам нам нужно отменить поворот, требующийся для задания позиции звезды. Когда вы "отменяете" поворот, вам нужно сделать это в порядке, обратным тому, который у вас был. Поэтому сначала мы отменяем поворот для задания позиции, а затем поворот наклона сцены (что было сделано в drawScene).

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


    if (twinkle) {
      // Draw a non-rotating star in the alternate "twinkling" color
      gl.uniform3f(shaderProgram.colorUniform, this.twinkleR, this.twinkleG, this.twinkleB);
      drawStar();
    }

    // All stars spin around the Z axis at the same rate
    mat4.rotate(mvMatrix, degToRad(spin), [0.0, 0.0, 1.0]);

    // Draw the star in its main color
    gl.uniform3f(shaderProgram.colorUniform, this.r, this.g, this.b);
    drawStar();

Давайте пока не будем обращать внимания на код, который отвечает за эффект мерцания. Эта звезда сначала поворачивается вокруг оси Z на spin градусов, поэтому она вращается вокруг собственного центра во время вращения вокруг центра сцены. Затем мы передаем цвет звезды в видеокарту через uniform-переменную шейдера, а затем вызываем глобальную функцию drawStar (до которой мы скоро доберемся).

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

Теперь, когда мы отрисовали звезду, нам осталось лишь восстановить нашу матрицу модель-вид из стека:


    mvPopMatrix();
  };

Следующий метод, который мы поместим в прототип, анимирует звезду:


  var effectiveFPMS = 60 / 1000;
  Star.prototype.animate = function(elapsedTime) {

Как и в предыдущих уроках, вместо обновления сцены как можно быстрее, я выбрал менять ее одинаково для всех, чтобы люди с быстрыми компьютерами получали плавную анимацию, а люди с медленными - анимацию скачками. Что же касается чисел для угловой скорости, на которой звезды вращаются вокруг центра сцены и на которой они двигаются к центру, я думаю, что они были тщательно вычислены NeHe, поэтому я решил здесь ничего не менять и предположил, что эти числа настроены на 60 кадров в секунду. Затем я использовал их и elapsedTime (которая, как вы помните, обозначает время с момента последнего вызова функции animate) для масштабирования дистанции, на которую мы смещаемся каждый вызов tick. elapsedTime измеряется в миллисекундах, поэтому мы хотим иметь эффективное значение кадров в секунду равное 60 / 1000. Мы храним это значение в глобальной переменной вне метода animate, поэтому оно не будет вычисляться каждый раз при отрисовке звезды (спасибо deathy/Brainstorm за это предложение).

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


    this.angle += this.rotationSpeed * effectiveFPMS * elapsedTime;

...и мы можем установить дистанцию от центра, вынося ее к внешней границе сцены и меняя цвет на случайный, когда она наконец достигнет центра:


    // Decrease the distance, resetting the star to the outside of
    // the spiral if it's at the center.
    this.dist -= 0.01 * effectiveFPMS * elapsedTime;
    if (this.dist < 0.0) {
      this.dist += 5.0;
      this.randomiseColors();
    }

  };

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


  Star.prototype.randomiseColors = function() {
    // Give the star a random color for normal
    // circumstances...
    this.r = Math.random();
    this.g = Math.random();
    this.b = Math.random();

    // When the star is twinkling, we draw it twice, once
    // in the color below (not spinning) and then once in the
    // main color defined above.
    this.twinkleR = Math.random();
    this.twinkleG = Math.random();
    this.twinkleB = Math.random();
  };

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


  function drawStar() {
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, starTexture);
    gl.uniform1i(shaderProgram.samplerUniform, 0);

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

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

    setMatrixUniforms();
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, starVertexPositionBuffer.numItems);
}

Немного выше вы можете видеть функцию initBuffers, который устанавливает эти координаты буферов вершин и координат, а затем функцию handleKeys для управления расстоянием и наклоном при нажатии стрелок и клавиш Page Up/Page Down. Еще немного выше вы увидите функции initTexture и handleLoadedTexture, которые изменились с загрузкой новой текстуры. Все это очень просто и я не буду утомлять вас, описывая все :).

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


  precision mediump float;

  varying vec2 vTextureCoord;

  uniform sampler2D uSampler;

  uniform vec3 uColor;

  void main(void) {
    vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
    gl_FragColor = textureColor * vec4(uColor, 1.0);
  }

...но ненамного :). Все, что мы здесь делаем - берем цвет uniform-переменной, который был помещен туда кодом метода draw, и используем его для подкрашивания текстуры, которая монохромная. Это означает, что наши звезды появляются с соответствующим цветом.

Вот и все! Подведем итог, что вы узнали из этого урока: как создавать объекты JavaScript для представления объектов сцены и как снабжать их методами для анимации и отрисовки.

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

Урок 10 >> << Урок 8

9 thoughts on “WebGL Урок 9 — Улучшение структуры кода на примере множества двигающихся объектов

  • 06.06.2016 at 09:09
    Permalink

    Здравстуйте, , я безумно рад, что нашел ваш сайт, Вы пролили свет, и много ясности на webGl, я неудержно благодарен.
    У меня есть вопрос, подскажиете пожалуйста, можно ли как то использовать гифку, у которой есть некое изображение, и все остальное пустое, что бы не было черных квадратов, или какой атрибут нужно использовать для получения желаемого ефекта.?

    Ответить
  • 06.06.2016 at 09:11
    Permalink

    Словом я пытаюсь добится, что бы отрисовка гифки происходила без черного квадрата, или что бы он был полностью прозрачным на любом фоне.
    Вы мне можете что то посоветывать?

    Ответить
    • 06.06.2016 at 22:32
      Permalink

      Рад, что материал оказался полезным :). В этой статье «прозрачность» достигается из-за того, что черные квадраты сливаются с черным фоном. Если нужен полностью прозрачный canvas со звездами, достаточно сделать прозрачную заливку ( gl.clearColor(0.0, 0.0, 0.0, 0.0); ) и заменить черный цвет в star.gif на прозрачный в каком-нибудь графическом редакторе. Тогда звезды будут генерироваться на прозрачном фоне — убедился в этом через добавление заливки body style=»background-color: red»

      Ответить
  • 08.07.2016 at 18:34
    Permalink

    Спасибо Вам огромное за Ваш развернутый ответ.
    Мне очень помогло, и я Вам благодарен.

    У меня еще есть вопрос по текстурам к Вам.
    Подскажите, что именно нужно использовать, что бы прорисовываемая в web gl
    картинка.png не меняла своих натуральных цветов.?
    Заранее, рад, что сделали огромную работу и поделились со мной своим опытом в виде этих уроков!
    Всех Благ!
    П.С. Никита

    Ответить
    • 09.07.2016 at 23:37
      Permalink

      Вышлите пример кода и картинку (ссылкой на Яндекс.Диск или что-то в этом духе), попробую помочь

      Ответить
  • 10.07.2016 at 08:55
    Permalink

    Да, спасибо.
    Вот я высылаю вам пример кода
    https://drive.google.com/file/d/0B3Zq6jTQUyvtMWNMYkVZb2FXa0k/view?usp=sharing
    Картинку можно брать любую, на ваше усмотрение.
    Просто сам факт, что любая картинка теряет свои цвета, или вообще становится полностью черной(
    Заранее спасибо!
    Вы, Сергей, нереально крут!

    Ответить
    • 11.09.2016 at 19:13
      Permalink

      Возможно проблема в освещении.
      Нужно подбирать какое-то правильное значение для освещения. Чтобы картинка не получалась ярче чем нужно.

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

      Ответить
  • 26.01.2017 at 16:04
    Permalink

    Когда мы задали позицию какому-нибудь объекту для отрисовки, отсчёт позиции для следующего объекта начинается с текущей позиции, а не центра сцены. Существует ли встроенная функция для возврата в начальные координаты? Было бы удобно вести отсчёт координат от одной и той же точки…

    Ответить
    • 30.01.2017 at 22:13
      Permalink

      В WebGL со «встроенным» вообще проблема :). В смысле, практически всё нужно делать самому. Но есть и готовые подходы. Взгляните на третий урок http://devburn.ru/webgl-урок-3-немного-движения — в нём описан один из подходов. С помощью функций mvPushMatrix и mvPopMatrix можно сохранить текущую позицию (например, центр сцены), а затем к ней вернуться.

      Иногда нужно возвращаться не к центру сцены, а к какому-то объекту. Например, солнечная система, где планеты отрисовываются относительно солнца, а спутники — относительно планет. Соответствующий (переведённый на русский) пример можно найти здесь — https://webglfundamentals.org/webgl/lessons/ru/webgl-scene-graph.html

      Ответить

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

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