Эта статья продолжает статью про ортогональ в WebGL. Если вы её ещё не читали, предлагаю начать с неё. Также вам понадобятся знания о текстурах и текстурных координатах, материал по которым можно найти в статье о 3D-текстурах.
Для создания большинства игр в 2D необходима лишь одна функция для отрисовки изображения. Конечно, в некоторых 2D-играх используются интересное использование линий и прочего, но если у вас есть только функция для отображения 2D-изображения на экране, это уже покроет большинство 2D-игр.
В программном интерфейсе Canvas 2D содержится очень гибкая функция для
отрисовки изображения под названием drawImage
. У неё есть 3 версии.
ctx.drawImage(image, dstX, dstY);
ctx.drawImage(image, dstX, dstY, dstWidth, dstHeight);
ctx.drawImage(image, srcX, srcY, srcWidth, srcHeight,
dstX, dstY, dstWidth, dstHeight);
После всего, что вы изучили, как бы вы реализовали эту функцию в WebGL? Первое, что приходит в голову - генерация вершин, что встречалось в первых наших статьях. Передача вершин в видеокарту - обычно медленная операция (хотя иногда это и будет быстрее).
Сейчас самое время для WebGL, чтобы проявить себя. Всё дело в том, чтобы творчески подойти к написанию шейдера, а затем всё так же творчески применить этот шейдер для решения задачи.
Начнём с первой версии функции.
ctx.drawImage(image, x, y);
Она отрисовывает изображение в координатах x, y
, размеры равны размерам
изображения. Для создания аналогичной функции на WebGL мы могли бы создать
вершины x, y
, x + width, y
, x, y + height
и x + width, y + height
,
а при генерации различных изображений в различных координатах мы бы
задавали соответствующий набор вершин.
Но более распространённый способ - использование квадрантов. Мы задаём единичный квадрат, а затем используем матрицы для масштабирования и переноса, чтобы квадрант расположился в нужном нам месте.
Перейдём к коду. Для начала нам понадобится простой вершинный шейдер.
attribute vec4 a_position;
attribute vec2 a_texcoord;
uniform mat4 u_matrix;
varying vec2 v_texcoord;
void main() {
gl_Position = u_matrix * a_position;
v_texcoord = a_texcoord;
}
И простой фрагментный шейдер.
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texcoord);
}
А теперь сама функция.
// В отличие от изображений, у текстур нет определённой ширины и высоты,
// поэтому мы сами установим ширину и высоту текстуры
function drawImage(tex, texWidth, texHeight, dstX, dstY) {
gl.bindTexture(gl.TEXTURE_2D, tex);
// Указываем нашу шейдерную программу для WebGL
gl.useProgram(program);
// Настраиваем атрибуты для получения данных из буферов
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
gl.enableVertexAttribArray(texcoordLocation);
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0);
// матрица для конвертации из пикселей в пространство отсечения
var matrix = m4.orthographic(0, gl.canvas.width, gl.canvas.height, 0, -1, 1);
// матрица переноса квадранта в координаты dstX, dstY
matrix = m4.translate(matrix, dstX, dstY, 0);
// эта матрица растянет наш единичный квадрант
// до размеров texWidth, texHeight
matrix = m4.scale(matrix, texWidth, texHeight, 1);
// устанавливаем матрицу
gl.uniformMatrix4fv(matrixLocation, false, matrix);
// указываем шейдеру, что текстуры нужно брать из блока 0
gl.uniform1i(textureLocation, 0);
// отрисовка квадранта (2 треугольника, 6 вершин)
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
Теперь загрузим несколько изображений в текстуры.
// создаём объект с информацией о текстуре { width: w, height: h, texture: tex }
// Текстура изначально будет размером 1х1 пиксель,
// затем размеры изменятся под загружаемое изображение
function loadImageAndCreateTextureInfo(url) {
var tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
// предполагаем, что размеры всех изображений не являются степенью двойки
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
var textureInfo = {
width: 1, // мы не знаем размер, пока изображение не загрузится
height: 1,
texture: tex,
};
var img = new Image();
img.addEventListener('load', function() {
textureInfo.width = img.width;
textureInfo.height = img.height;
gl.bindTexture(gl.TEXTURE_2D, textureInfo.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
});
return textureInfo;
}
var textureInfos = [
loadImageAndCreateTextureInfo('resources/star.jpg'),
loadImageAndCreateTextureInfo('resources/leaves.jpg'),
loadImageAndCreateTextureInfo('resources/keyboard.jpg'),
];
Отобразим изображения в случайных местах.
var drawInfos = [];
var numToDraw = 9;
var speed = 60;
for (var ii = 0; ii < numToDraw; ++ii) {
var drawInfo = {
x: Math.random() * gl.canvas.width,
y: Math.random() * gl.canvas.height,
dx: Math.random() > 0.5 ? -1 : 1,
dy: Math.random() > 0.5 ? -1 : 1,
textureInfo: textureInfos[Math.random() * textureInfos.length | 0],
};
drawInfos.push(drawInfo);
}
function update(deltaTime) {
drawInfos.forEach(function(drawInfo) {
drawInfo.x += drawInfo.dx * speed * deltaTime;
drawInfo.y += drawInfo.dy * speed * deltaTime;
if (drawInfo.x < 0) {
drawInfo.dx = 1;
}
if (drawInfo.x >= gl.canvas.width) {
drawInfo.dx = -1;
}
if (drawInfo.y < 0) {
drawInfo.dy = 1;
}
if (drawInfo.y >= gl.canvas.height) {
drawInfo.dy = -1;
}
});
}
function draw() {
gl.clear(gl.COLOR_BUFFER_BIT);
drawInfos.forEach(function(drawInfo) {
drawImage(
drawInfo.textureInfo.texture,
drawInfo.textureInfo.width,
drawInfo.textureInfo.height,
drawInfo.x,
drawInfo.y);
});
}
var then = 0;
function render(time) {
var now = time * 0.001;
var deltaTime = Math.min(0.1, now - then);
then = now;
update(deltaTime);
draw();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
Теперь можно посмотреть демонстрацию.
Займёмся второй версией функции drawImage
ctx.drawImage(image, dstX, dstY, dstWidth, dstHeight);
Никакой принципиальной разницы здесь нет. Мы просто используем dstWidth
и dstHeight
вместо texWidth
и texHeight
.
*function drawImage(
* tex, texWidth, texHeight,
* dstX, dstY, dstWidth, dstHeight) {
+ if (dstWidth === undefined) {
+ dstWidth = texWidth;
+ }
+
+ if (dstHeight === undefined) {
+ dstHeight = texHeight;
+ }
gl.bindTexture(gl.TEXTURE_2D, tex);
...
// матрица для конвертации из пикселей в пространство отсечения
var projectionMatrix = m3.projection(canvas.width, canvas.height, 1);
// эта матрица растянет наш единичный квадрант
* // до размеров texWidth, texHeight
* var scaleMatrix = m4.scaling(dstWidth, dstHeight, 1);
// матрица переноса квадранта в координаты dstX, dstY
var translationMatrix = m4.translation(dstX, dstY, 0);
// умножаем матрицы
var matrix = m4.multiply(translationMatrix, scaleMatrix);
matrix = m4.multiply(projectionMatrix, matrix);
// устанавливаем матрицу
gl.uniformMatrix4fv(matrixLocation, false, matrix);
// указываем шейдеру, что текстуры нужно брать из блока 0
gl.uniform1i(textureLocation, 0);
// отрисовка квадранта (2 треугольника, 6 вершин)
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
Я внёс изменения в код, чтобы использовались другие размеры.
Это было просто. Но как насчет третьей версии drawImage
?
ctx.drawImage(image, srcX, srcY, srcWidth, srcHeight,
dstX, dstY, dstWidth, dstHeight);
Для выбора фрагмента текстуры нам нужно поработать с текстурными координатами. Работа текстурных координат рассмотрена в статье о текстурах. В этой статье мы вручную создавали текстурные координаты, что является очень распространённым подходом, но мы можем создать их и на лету, а затем управлять текстурными координатами через матрицы - так же, как мы управляли через матрицы координатами вершин.
Давайте добавим матрицу текстуры в вершинный шейдер, а затем умножим текстурные координаты на эту матрицу.
attribute vec4 a_position;
attribute vec2 a_texcoord;
uniform mat4 u_matrix;
+uniform mat4 u_textureMatrix;
varying vec2 v_texcoord;
void main() {
gl_Position = u_matrix * a_position;
* v_texcoord = (u_textureMatrix * vec4(a_texcoord, 0, 1)).xy;
}
Теперь получим ссылку на матрицу текстуры.
var matrixLocation = gl.getUniformLocation(program, "u_matrix");
+var textureMatrixLocation = gl.getUniformLocation(program, "u_textureMatrix");
Затем внутри drawImage
необходимо необходимо задать значение этой матрицы,
чтобы она вырезала нужную часть текстуры. Мы знаем, что текстурные координаты -
это по сути единичный квадрант, поэтому работа с ним очень похожа на работу с
координатами вершин.
*function drawImage(
* tex, texWidth, texHeight,
* srcX, srcY, srcWidth, srcHeight,
* dstX, dstY, dstWidth, dstHeight) {
+ if (dstX === undefined) {
+ dstX = srcX;
+ }
+ if (dstY === undefined) {
+ dstY = srcY;
+ }
+ if (srcWidth === undefined) {
+ srcWidth = texWidth;
+ }
+ if (srcHeight === undefined) {
+ srcHeight = texHeight;
+ }
if (dstWidth === undefined) {
* dstWidth = srcWidth;
}
if (dstHeight === undefined) {
* dstHeight = srcHeight;
}
gl.bindTexture(gl.TEXTURE_2D, tex);
...
// матрица для конвертации из пикселей в пространство отсечения
var projectionMatrix = m3.projection(canvas.width, canvas.height, 1);
// эта матрица растянет наш единичный квадрант
// до размеров texWidth, texHeight
var scaleMatrix = m4.scaling(dstWidth, dstHeight, 1);
// матрица переноса квадранта в координаты dstX, dstY
var translationMatrix = m4.translation(dstX, dstY, 0);
// умножаем матрицы
var matrix = m4.multiply(translationMatrix, scaleMatrix);
matrix = m4.multiply(projectionMatrix, matrix);
// устанавливаем матрицу
gl.uniformMatrix4fv(matrixLocation, false, matrix);
+ // Так как текстурные координаты располагаются в диапазоне
+ // от 0 до 1 и потому, что наши текстурные координаты уже
+ // являются единичным квадрантом, мы можем выбрать нужную
+ // область текстуры через сжатие единичного квадранта
+ var texMatrix = m4.translation(srcX / texWidth, srcY / texHeight, 0);
+ texMatrix = m4.scale(texMatrix, srcWidth / texWidth, srcHeight / texHeight, 1);
+
+ // устанавливаем текстурную матрицу
+ gl.uniformMatrix4fv(textureMatrixLocation, false, texMatrix);
// указываем шейдеру, что текстуры нужно брать из блока 0
gl.uniform1i(textureLocation, 0);
// отрисовка квадранта (2 треугольника, 6 вершин)
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
Также я изменил код, чтобы выбирались различные части текстур. Вот результат.
В отличии от функции canvas из его 2D API наша версия функции на WebGL
имеет расширенные возможности, которыех нет в canvas-функции drawImage
.
Например, мы можем передать отрицательные значения ширины или высоты как
для исходного изображения, так и для итогового. Отрицательное значение
srcWidth
выберет пиксели слева от srcX
. А отрицательное dstWidth
возьмёт пиксели слева от dstX
. В 2D-функции canvas отрицательные значения -
это ошибки в лучшем случае, а в худшем - непредсказуемое поведение.
Кроме того, использование матриц даёт нам возможность выполнять любую математику с матрицами.
Например, мы можем вращать текстурные координаты вокруг центра текстуры.
Изменим код текстурной матрицы следующим образом
* // По аналогии с проекционной матрицей нам нужно переместиться из текстурного
* // пространства (0..1). Эта матрица перенесёт нас в пиксельное пространство.
* var texMatrix = m4.scaling(1 / texWidth, 1 / texHeight, 1);
*
* // Нам нужно выбрать точку, вокруг которой будет происходить вращение.
* // Мы сместимся в центр, осуществим поворот и вернёмся обратно в начальную точку.
* var texMatrix = m4.translate(texMatrix, texWidth * 0.5, texHeight * 0.5, 0);
* var texMatrix = m4.zRotate(texMatrix, srcRotation);
* var texMatrix = m4.translate(texMatrix, texWidth * -0.5, texHeight * -0.5, 0);
*
* // так как мы находимся в пиксельном пространстве,
* // масштабирование и перенос выполняется в пикселях
* var texMatrix = m4.translate(texMatrix, srcX, srcY, 0);
* var texMatrix = m4.scale(texMatrix, srcWidth, srcHeight, 1);
// устанавливаем текстурную матрицу
gl.uniformMatrix4fv(textureMatrixLocation, false, texMatrix);
И вот, что в итоге получилось.
Здесь видно одну проблему. Из-за поворота мы иногда попадаем за край текстуры.
Этот край начинает повторяться, пока не достигнет края картинки, это происходит
за счёт заданного значения CLAMP_TO_EDGE
.
Мы могли бы исправить эту ситуацию, отменив в шейдере отрисовку пикселей за
пределами диапазона (0, 1). Функция discard
немедленно выходит из шейдера
без отрисовки пикселя.
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D texture;
void main() {
+ if (v_texcoord.x < 0.0 ||
+ v_texcoord.y < 0.0 ||
+ v_texcoord.x > 1.0 ||
+ v_texcoord.y > 1.0) {
+ discard;
+ }
gl_FragColor = texture2D(texture, v_texcoord);
}
Теперь размытых углов не стало.
Или, возможно, вы предпочтёте сплошной цвет за краями текстуры.
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D texture;
void main() {
if (v_texcoord.x < 0.0 ||
v_texcoord.y < 0.0 ||
v_texcoord.x > 1.0 ||
v_texcoord.y > 1.0) {
* gl_FragColor = vec4(0, 0, 1, 1); // синий
+ return;
}
gl_FragColor = texture2D(texture, v_texcoord);
}
Как видите, при работе с шейдерами всё ограничивается лишь вашей фантазией.
В следующий раз мы реализуем функцию стека матриц из 2D-canvas.