WebGL实战篇(四)—— 仿射变换

2,337 阅读7分钟

传送门:

  1. WebGL概述——原理篇
  2. WebGL实战篇(一)—— 绘制点、三角形
  3. WebGL实战篇(二)—— 绘制点、三角形(进阶)
  4. WebGL实战篇(三)—— 绘制图片

前言

(多图预警!!!流量党慎重,土豪随意)

接上一节中的问题,为什么下面这高达的图片会变形?我们又该怎么做而不让它变形呢?

其实这个问题的答案很简单: 就是因为我们绘制的矩形的长宽比与图片本身的长宽比不一致导致的嘛!问题在于我们该如何做让这个图片变得和原来一样?

方案1:

有的同学说:我们在设置顶点位置的时候保持矩形的长宽比与图片的长宽比一样不就好了吗?

这是一个不错的主意,我们可以尝试一下,这个图片的大小为 533x300,长宽比为533 / 300 = 1.77, 保持长度不变的情况下,我们的宽度应该变为:2 / 1.77 = 1.125,所以我们需要修改我们的顶点数据为:

const pointPos = [
    -1, 0.5625,
    -1, -0.5625,
    1, -0.5625,
    1, -0.5625,
    1, 0.5625,
    -1, 0.5625,
];

OK,我们发现的确变成我们想要的样子了。

但是,请等等!这里我们的canvas尺寸是500x500,那如果我们的canvas尺寸不是500x500呢?让我们把canvas的尺寸改为480x270,结果又如何呢?

Oh,No!怎么又变形了?!!这意味这这不仅仅和顶点数据的长宽比有关系,还和canvas的长宽比有关系了。说到这里相必你的脑子里面应该已经很绕了吧!我们有没有简单一点的方式来解决这个问题呢?

方案2:

答案肯定是有的。我们注意到,我们一直都是在使用-1~1之间的数字来表示坐标信息,我能不能用具体的实际坐标表示我们的顶点数据呢?比如在500x500的canvas中直接用(300, 400)表示点的位置

这肯定是可以的。这就是我们今天要讲的其中一部分:坐标变换

坐标变换

我们把这个问题转换一下:这个问题实际上是将某个范围的坐标转换到另一个范围中。在WebGL中,我们实际上是要将左边界L到右边界R之间的坐标转换到-1 ~ 1之间。纵坐标同理。

我们可以通过不等式变换来求解这个问题:

同理

有了以上的两个式子过后我们就可以进行坐标变换了,将0~480的坐标映射到 -1~1 之间。我们修改一下数据:


let pointPos = [
    0, 0,
    533, 0,
    533, 300,
    533, 300,
    0, 300,
    0, 0
];
const texCoordPos = [
    0, 0,
    1, 0,
    1, 1,
    1, 1,
    0, 1,
    0, 0
];

再编写一个转换坐标的函数


const convert = (l, r) => {
    return function (cordinate) {
        return 2 * cordinate / (r - l) - (r + l) / (r - l);
    }
}

const convertX = convert(0, canvas.width);
const convertY = convert(0, canvas.height);

for (let i = 0; i < pointPos.length; i += 2) {
    pointPos[i] = convertX(pointPos[i]);
    pointPos[i + 1] = convertY(pointPos[i + 1]);
}

这样,我们就可以用真实的坐标来表示图片的位置了。现在我们学会了如何使用真实的坐标表示位置,但是我们是在js代码中完成的坐标变换,这感觉略微的有点麻烦,我们是否可以在顶点着色器中去完成坐标变换呢。答案是可以的。我们可以利用矩阵与向量的乘法。

在顶点着色器中,是使用一个4维向量表示的一个点的位置。所以我们可以使用一个4x4的矩阵来对这个点进行变换。这里需要给大家普及一下矩阵乘法的基本知识

矩阵

什么是矩阵

一个M x N的矩阵是一个由M行和N列元素排列成的矩形阵列。形如下图:

矩阵乘法

两个矩阵M、N相乘必须满足条件:矩阵M的列数必须与矩阵N的行数相等。他们相乘的结果可以表示为:

所以一个点与矩阵相乘的结果为:

得到的结果每一行分别代表x, y, z, w的值。这里的x, y, z很容易理解,w是齐次坐标中的齐次项。有w值的坐标可以理解为:

(x, y, z, w) <==> (x / w, y / w, z / w)

现在我们推导出了坐标变换矩阵,我们可以通过一个函数来生成这样的一个矩阵。

export function createProjectionMat(l, r, t, b, n, f) {
    return [
        2 / (r - l), 0, 0, 0,
        0, 2 / (t - b), 0, 0,
        0, 0, 2 / (f - n), 0,
        -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1
    ]
}

接下来,我们把变换矩阵在shader中表示,修改我们的shader程序如下:

const vertexShader = `
attribute vec4 a_position;
attribute vec2 a_texCoord;
uniform mat4 u_projection;
varying vec2 v_texCoord;
void main () {
    gl_Position = u_projection * a_position;
    v_texCoord = a_texCoord;
}  
`;

shader程序中多了一个uniform变量,所以我们现在要往其中传入这个变换矩阵,我们可以使用 uniformMatrix4fv这个API。

// 先获取u_projection在shader的位置
const u_projection = gl.getUniformLocation(program, 'u_projection');
// 生成坐标变换矩阵
const projectionMat = createProjectionMat(0, width, height, 0, 0, 1);
// 传入数据
gl.uniformMatrix4fv(u_projection, false, projectionMat);

好了,完成!

我们已经完成了坐标变换这一内容,接下来我们会继续讲解仿射变换的内容。

仿射变换

什么是仿射变换

简单的将,仿射变换就是线性变换 + 平移。我们可以使用仿射变换对图形进行平移、缩放、旋转等操作,为什么平移会单独的拿出来说,别着急,后面我们解释这个问题。我们先来看一些线性变换。

缩放

所犯更应该很容易理解,就是给坐标乘上一个数。左下角是代数表示,右下角是矩阵表示。

旋转

旋转看起来就不是那么容易理解的了,为什么旋转是这样表示的呢。下面给出简单的推导,我们先考虑一个点的旋转过后如何表示:

至此,推导完毕。

平移

平移就更简单了,就是给坐标加上一个数就是表示平移。我们发现,对于二维坐标,我们无法使用2x2的矩阵和2维向量的乘法来表示这样的加减关系

所以,齐次坐标应运而生了。

齐次坐标

平面上的任何点都可以表示成一三元组 (X, Y, W),称之为该点的齐次坐标

当 W 不为 0,则该点表示欧氏平面上的 (X/W, Y/W)

那么,我们使用齐次坐标表示平移,如下:

同理,对于之前的缩放、旋转,我们可以改写为:

这里我们需要注意一点:

由于矩阵的乘法不具有交换性质,所以缩放、平移、旋转之间是具有顺序性的。我们可以讲这个几个矩阵以不同的方式相乘后观察他们的结果:

我们可以根据上面的结果看出: 虽然都是缩放、平移、旋转矩阵彼此相乘,只是交换了他们的顺序,但是得出的结果是完全不同的。所以我们在使用的时候需要注意这一点。

我们现在将程序改写一下,让其可以进行平移、旋转、缩放的操作。

先编写创建变换矩阵的函数


export function createTranslateMat(tx, ty) {
    return [
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        tx, ty, 0, 1
    ]
}

export function createRotateMat(rotate) {
    rotate = rotate * Math.PI / 180;
    const cos = Math.cos(rotate);
    const sin = Math.sin(rotate);
    return [
        cos, sin, 0, 0,
        -sin, cos, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    ]
}

export function createScaleMat(sx, sy) {
    return [
        sx, 0, 0, 0,
        0, sy, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1
    ]
}

修改Shader如下:

const vertexShader = `
attribute vec4 a_position;
attribute vec2 a_texCoord;
uniform mat4 u_projection;
uniform mat4 u_rotate;
uniform mat4 u_scale;
uniform mat4 u_translate;
varying vec2 v_texCoord;
void main () {
    gl_Position = u_projection * u_translate * u_rotate * u_scale * a_position;
    v_texCoord = a_texCoord;
}  
`;

// 获取shader中新增的变换矩阵的位置
const u_translate = gl.getUniformLocation(program, 'u_translate');
const u_scale = gl.getUniformLocation(program, 'u_scale');
const u_rotate = gl.getUniformLocation(program, 'u_rotate');

// 创建变换矩阵
let translateMat = createTranslateMat(0, 0);
let rotateMat = createRotateMat(0);
let scaleMat = createScaleMat(1, 1);

// 传入数据
gl.uniformMatrix4fv(u_translate, false, translateMat);
gl.uniformMatrix4fv(u_rotate, false, rotateMat);
gl.uniformMatrix4fv(u_scale, false, scaleMat);

我们再加入一些UI控件,我们就可以很方便的操控这张图片的位置了。

总结

至此,你已经学会了如何使用仿射变换操作图形的位置了。我们简单回顾一下今天的内容:

  1. 我们讲解了如何讲一个具体的坐标转换为WebGL坐标系空间的(-1 ~ 1)的坐标范围内。(坐标系变换)
  2. 我们讲解了矩阵相关的知识,你应该掌握如何使用矩阵来表示变换的过程。
  3. 我们讲解了仿射变换,仿射变换就是 线性变换 + 平移
    • 线性变换有: 旋转、缩放
    • 为了方便使用矩阵表示平移,我们引入了齐次坐标的概念,通过齐次坐标我们可以将线性变换与平移都使用矩阵进行表示

好了,今天的内容就到此为止了,接下来我们会继续讲述一些关于图像处理的内容。如果你觉得文章不错的话,记得点个赞哦!!