Урок7 >>
Добро пожаловать в мой урок номер шесть в серии WebGL, основанный на уроке номер 7 в учебнике OpenGL от NeHe. Речь пойдёт о том, как вы можете реализовать обработку ввода с клавиатуры в своих WebGL страницах. Это потребуется нам для изменения скорости и направления вращения текстурированного куба, а также для изменения вида фильтрации, применяемого к текстуре, для получения низкого качества, но более быстрого, или более качественного, но медленного рендеринга. (Урок NeHe № 7 охватывает ещё и освещение. Так как освещение в WebGL более трудоёмко, чем в OpenGL, я не включил его в этот урок - мы рассмотрим его в следующий раз.)
Здесь вы можете посмотреть, как выглядит этот урок при работе в браузере, который поддерживает WebGL, лучше всего просматривать его на сайте YouTube, чтобы можно было увидеть аннотации в которых рассказывается о том, что я делаю, для того, чтобы изменить сцену:
Нажмите здесь для просмотра этого урока WebGL "вживую", если ваш браузер поддерживает WebGL.
В ином случае вы можете прочитать как получить браузер с поддержкой WebGL здесь.
После того как вы загрузили урок в браузер, вы можете использовать клавиши Page Up и Page Down для изменения zoom'а, и использовать клавиши перемещения курсора, чтобы вращать куб (чем дольше вы удерживаете нажатыми клавиши перемещения курсора, тем больше становится скорость вращения). Вы можете также использовать клавишу F для переключения между тремя различными видами текстурных фильтров, в зависимости от того, как вам лучше рассматривать полученный эффект, когда вы находитесь совсем близко по отношению к кубу или когда вы довольно далеко от него.
Подробнее о том, как все это работает ниже ...
Примечание : эти уроки ориентированы на людей с достаточным количеством знаний в области программирования, но не имеющих реального опыта работы с 3D-графикой. Цель уроков состоит в том, чтобы вы начали производить свои собственные 3D веб-страницы как можно быстрее,имея хорошее представление о том, как работает данный код. Если вы ещё не ознакомились с предыдущими уроками, рекомендую сделать вам это до прочтения данного урока - здесь я буду только объяснять различия между кодом для Урок 5 и новым кодом.
В тексте могут быть ошибки и неточности. Если вы заметили ошибку, дайте мне знать в комментариях и я исправлю это как можно скорее.
Есть 2 способа получить код примера из этого урока: используйте "View Source", если вы просматриваете урок в браузере с поддержкой WebGL "вживую", или вы можете загрузить его отсюда GitHub.
В любом случае, когда вы получите код, загрузите его в ваш любимый текстовый редактор и просмотрите.
Самым значительным изменением между этим и предыдущим уроком является то, что теперь мы реагируем на нажатия пользователя на клавиатуре. Легче всего объяснить как это работает, это показать вам те части кода, которые отвечают за приём нажатий на клавиатуре.
Если вы начнёте просматривать код и промотаете до середины, то вы увидите, какое количество глобальных переменных определено:
var xRot = 0;
var xSpeed = 0;
var yRot = 0;
var ySpeed = 0;
var Z = -5,0;
var filter = 0;
xRot и yRot знакомы вам с Урока 5 - они представляют текущее вращение куба вокруг осей х и у. Теперь, когда мы, позволяем пользователю изменять скорость вращения куба при помощи клавиш управления курсором, xSpeed и ySpeed содержат скорости изменения xRot и yRot .
Z - конечно-же Z-координата куба - то есть то, как близко или далеко он находится по отношению к наблюдателю, она будет контролироваться клавишами Page Up и Page Down. И, наконец, фильтр, он является целым числом от 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 становится ясно что код действительно изменился, схожесть в том, что мы создаём 3 объекта текстур WebGL в массиве и передаём этот массив в функцию обратного вызова handleLoadedTexture, когда изображение уже загружено. И, конечно, мы загружаем другое изображение crate.gif вместо nehe.gif.
handleLoadedTexture сильно не изменилась; в прошлый раз мы устанавливали единственный текстурный объект WebGL с данными изображения, и устанавливали для него 2 параметра gl.TEXTURE_MAG_FILTER и gl.TEXTURE_MIN_FILTER, обоим присваивалось значение gl.NEAREST. Теперь мы устанавливаем все 3 текстуры в наш массив для одного и того же изображения, но для каждого устанавливаем разные параметры, и это важное отличие от кода предыдущего урока.
Здесь о различиях текстур в подробностях:
Приближённая фильтрация
У первой текстуры оба парметра gl.TEXTURE_MAG_FILTER и gl.TEXTURE_MIN_FILTER установлены в значение gl.NEAREST. Это наша первоначальная настройка, которая означает, что в обоих случаях, когда текстура сжимается и растягивается, WebGL будут использовать фильтр, который определяет цвет точки как цвет ближайшей к ней точки исходного изображения. Для того, чтобы куб хорошо смотрелся текстура не должна быть растянута вообще или должна быть уменьшена в масштабе ( дискуссию по поводу сглаживания см. ниже ). А вот когда она растягивается, это выглядит "пиксельно", потому что алгоритм как бы растягивает пиксели оригинального изображения.Линейная фильтрация
Для второй текстуры оба параметра gl.TEXTURE_MAG_FILTER и gl.TEXTURE_MIN_FILTER установлены в gl.LINEAR. Здесь мы опять используем тот же фильтр, что и для растягивания/сжимания. Однако линейный алгоритм для растянутых текстур работает лучше, он использует линейную интерполяцию между пикселами исходного изображения, грубо говоря, пиксел, который находится посередине между белым и чёрным получается серым. Это даёт эффект размытия, острые углы сглаживаются. ( Будьте уверены, что растягивание изображения никогда не улучшает его качества - вы не сможете разглядеть более мелкие детали изображения, если их нет ).Мип
Для третьей текстуры gl.TEXTURE_MAG_FILTER и gl.TEXTURE_MIN_FILTER установлены в gl.LINEAR_MIPMAP_NEAREST. Это самая сложная из всех трёх опций.Линейная фильтрация оправдывает себя, когда вы растягиваете текстуру, но когда вы сжимаете текстуру она не эффективнее, чем приближённая фильтрация, по факту оба фильтра могут изуродовать картинку подобным сглаживаением. Для просмотра этого снова загрузите пример, где используется приближённая фильтрация, или нажмите кнопку обновить для получения примера в исходном состоянии. На несколько секунд зажмите кнопку Page Up для уменьшения масштаба. Когда кубик будет удаляться, вы увидите как он начнёт "мерцать": кажется будто вертикальные линии то появляются, то исчезают. Как только вы это увидите, остановитесь и попробуйте сначала немного приблизить, а затем немного отодвинуть картинку. После того как увидите мерцание нажмите клавишу F 1 раз для того, чтобы переключиться на линейную фильтрацию, снова немного приблизьте и отдалите, отметьте для себя, что результат получился почти такой же. Теперь нажмите F ещё раз для применения мип-фильтрации, снова чуть-чуть приблизьте и отдалите отображение и вы увидите, что эффект мерцания исчез или стал почти незаметен.
Теперь, когда куб находится достаточно далеко от наблюдателя, скажем, его ширина/высота составляют 10% от размеров экрана - попробуйте попереключаться между фильтрами не перемещая куб. При приближённой или линейной фильтрации вы увидете шум : тёмные линии в некоторых местах на волокнах дерева очень заметны, в то время как в других местах их нет: куб как-бы выглядит немного испачканным. Это действительно выглядит очень плохо при приближённой фильтрации, но не сильно лучше и при линейной. Только мип-фильтрации выглядит хорошо.
Что же происходит с текстурами при применении приближённой или линейной фильтрации при сжимании текстуры? Фильтр использует каждый 10тый пиксел оригинального изображения для создания уменьшенной версии изображения. Текстура представляет собой образец деревянного волокна, это означает, что большая часть текстуры светло коричнего цвета с тёмными вертикальными полосами, давайте представим, что зерно текстуры имеет толщину 10 пикселов, или другими словами, каждый 10 пиксел по горизонтали тёмно-коричневого цвета. Если изображение ужимается в 10 раз, то мы получаем шанс 1 из 10ти, что выбраный нами пиксел для сжатого изображения будет тёмно-коричневого цвета, 9 из 10 в таком случае будут светлыми. Другими словами, одна из 10ти тёмных линий исходного изображения будет выглядеть также как на полноразмерном изображении, все остальные линии будут скрыты. Этот эффект исчезновения и порождает мерцание при изменении размера текстуры, поскольку указанные тёмные линии могут быть совершенно разными для разных коэффициентов сжатия 9.9, 10.0 и 10.1.
Нам бы всегда хотелось оказываться в такой ситуации, когда размер тесктуры изменяется каждый раз на десятую часть её оригинального размера. Каждый пиксел, принадлежащий уменьшенной копии текстуры, отображается цветом, являюющимся средним арифметическим между значениями цветов пикселов квадрата10Х10. Делать это плавно слишком дорого по вычислениям для графики реального времени, вот тут на помощь и приходит мип-фильтрация.
Мип-фильтрация решает для текстуры проблему генерации некоторого количества производных изображений, называемых мип-уровнями, половина, четверть, одна восьмая оригинального изображения и т.д. вплоть до изображения в 1 пиксел. Набор этих мипов всех уровней называется мипмап. Каждый мип-уровень является плавно уменьшенной копией большего на уровень мипа, таким образом для текущего уровня сжатия всегда можно подобрать соотвествующий мип-уровень, алгоритм выбора зависит от параметра gl.TEXTURE_MIN_FILTER, тот алгоритм, который выберем мы означает "найди ближайший мип-уровень и примени к нему линейную фильтрацию для получения цвета пиксела".
Теперь, когда всё объяснено, должно быть предельно ясно, зачем нужна следующая строка:
gl.generateMipmap(gl.TEXTURE_2D);
...эта строка сообщает WebGL, что необходимо сгенерировать мипмап.
Получилось существенно больше, чем я планировал написать о мипмапах, но я думаю, что получилось достаточно ясно:-). Дайте мне знать в комментариях, если что-то осталось вам непонятно.
Вернёмся к остальной части кода. До сих пор мы смотрели на глобальные переменные и наблюдали за тем, как текстуры загружаются и устанавливаются. Теперь давайте посмотрим на то, как они используются, когда дело доходит до отображения на сцене.
drawScene находится через три четверти страницы и в неё внесено 3 изменения. Первое изменение: когда мы позиционируем наблюдателя при рисовании куба вне заивисимости от точки привязки, мы используем глобальную переменную z:
mat4.translate(mvMatrix, [0.0, 0.0, z]);
Следующим важным изменением является, которую мы удалили из кода 5го урока,теперь мы вообще не будем вращать вокруг оси z, а заменим на вращения вокруг осей x и y:
mat4.rotate(mvMatrix, degToRad(xRot), [1, 0, 0]);
mat4.rotate(mvMatrix, degToRad(yRot), [0, 1, 0]);
И, наконец, перед отрисовкой куба мы должны указать, какую из 3-х текстур мы хотим использовать:
gl.bindTexture(gl.TEXTURE_2D, crateTextures[filter]);
На этом изменения в drawScene закончены. Ещё немного незначительных изменений в анимации; вместо изменения xRot и yRot на постоянную величину, мы теперь используем наши новые переменные xSpeed и ySpeed:
xRot += (xSpeed * elapsed) / 1000.0;
yRot += (ySpeed * elapsed) / 1000.0;
И на это всё об изменениях в коде за исключением обработки нажатий пользователем клавиш и изменения в глобальных переменных, привязанных к этому.
Вот первое изменение, в webGLStart мы добавили 2 новые строки ( ниже, выделены красным)
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 ) может рассказать нам о том, нажимал ли пользователь уже данную клавишу или нет. Если вам не знакомы принципы работы JavaScript, вам может показаться интересным, что некоторый объект можно использовать в качестве словаря, как например этот, синтаксис, котрый мы используем для инициализации currentlyPressedKeys похож на синтаксис создания словаря в Python, на самом деле он представляет собой "пустую" подстановку объекта базового типа.
В дополнение к поддержанию в актуальном состоянии словаря кодов уже нажатых клавиш, у нас есть дополнительный обработчик для кливиши ВНИЗ ( клавиша "F"). В этом примере мы меняем глобальную переменную фильтр, принимающую значения 0, 1 и 2 при всяком нажатии клавиши "F".
Стоит объяснить зачем мы обработываем различные нажатия разными способами.
В компьютерной игре или в любой другой 3D-системе, нажатие клавиш может происходить 2мя спсособами:
1. Реакция на нажатие может возникать мгновенно - "как огонь лазера". Такие нажатия могут происходить с любой или фиксированной скоростью, например 2 раза в секунду.
2. Скорость возникновения реакции на нажатие может зависеть от того, как долго вы удерживали данную клавишу нажатой. Например, когда вы зажимаете клавишу ВПЕРЕД, вы ожидаете, что движение вперёд будет продолжаться до тех пор, пока клавиша зажата. Важно, чтобы была возможность одновременной обработки нескольких нажатий одновременно, например вы начинаете движение ввперёд, затем поворачиваетесь и стреляете без остановки движения. Эта обработка нажатий значительно отличается от обработки нажатий клавиш при обычном режиме набора текста. Если вы зажмёте клавишу "A" в обработчике слов, вы получите несколько букв "A" в потоке, однако если вы нажмёте клавишу "B" во время удерживания клавиши "А", то вы получите букву "В", а поступление букв "А" в поток остановится. Эквивалентом этой ситуации в игре является отсановка движения вскяий раз при повороте, что будет очень раздражать пользователя.
Итак, в нашем коде нажатия клавиши "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-анимации.
Если у вас имеются вопросы, комментарии или предложения, пожалуйста напишите об этом в комментарии ниже.
Следующий урок будет посвящён теме освещения.
Благодарности: Chris Marrin’s WebKit-only spinning box сильно помогло при написании данного материала, так же как и портирование Крисом демо Jacob Seidelin’s в Firefox.
Как всегда, я глубоко благодарен NeHe за их уроки OpenGL и за скрипты к этому уроку.