Press "Enter" to skip to content

Three.js 引擎揭秘之矩阵变换

本站内容均来自兴趣收集,如不慎侵害的您的相关权益,请留言告知,我们将尽快删除.谢谢.

Three.js 引擎揭秘之矩阵变换

 

 

在上一章的内容中,使用 Three.js 实现了一个3D的小骰子,在线游玩Demo

 

上一章传送门: 耗时3小时,实现一个3D的小骰子

 

在这一章中,我们会探究隐藏在 Three.js 背后的一些秘密,本次需要线性代数作为前置知识,如果没有学习过或已经忘的差不多了,可以先去简单温习一下.

 

大学期间学习线性代数中的矩阵运算,总是无法理解为什幺要设计这样的运算规则,没想到工作三年之后,居然会再次使用到大学期间学习的数学知识,而且搞懂了曾经的疑问.

 

理解矩阵运算

 

矩阵运算的本质是方程组的运算,假设我们有一组输入值 x1,x2,我们通过一系列方程运算后会得到一个组输出值 y1,y2.如下所示

 

a1 * x1 + a2 * x2 = y1
a3 * x1 + a4 * x2 = y2

 

我们可以将上面的方程组转换成 矩阵乘向量 的形式

 

a1,a2     x1       y1
       X       =
a3,a4     x2       y2

 

看到这个转换似乎有一种茅塞顿开的感觉,每当我有一个多维的值(向量)需要经过一些处理来得到另一个多维的值(向量)时,就会用到矩阵运算,只不过在之前我们是通过方程组的方式来展示的,比如坐标值 (x,y,z), 颜色值(r,g,b), 抑或是其他有关联的关键指标,如(成本、营收、利润). 本文后续都会用向量来表示多维值

 

通过矩阵运算,我们将输入向量转换为输出向量,在 WebGL 中,矩阵运算的主要用途是计算顶点坐标值以及颜色值.

 

在 3D 骰子的这个渲染中,由于是 3D 模型,它的坐标转换比较复杂,所以本次我们先通过一个 2D 场景下的坐标变换来展示矩阵的魅力.

 

场景模拟

 

我们通过 Div 在页面上画一个黄色盒子,并给予它一定的 css 属性

 

.box {
  left: 200px;
  top: 200px;
  position: fixed;
  background-color: blanchedalmond;
  border: 1px solid salmon;
  width: 200px;
  height: 100px;
}

 

我们假设页面的坐标系为 向右 为 X 轴正方向, 向下 为 Y 轴正方向,左上角为原点(0,0) 根据这个 CSS 属性,我们已知盒子中心点(P)的坐标为(300, 250), 盒子的宽为 200, 高为 100,那幺可以很容易的算出 A\B\C\D 四个点的坐标位置

 

如果此时我们给盒子加上一个新的 CSS 属性,这时再计算 A、B、C、D 四个点的坐标位置就变的困难了

 

.transform {
  transform: translate(50px, 0px) rotate(45deg) scale(2, 1);
}

 

先来看一下加完属性后的样子,黄色矩形为变换前的矩形,红色矩形为变换后的矩形…直觉告诉我计算这个图形的 A、B、C、D 点坐标绝非易事!

 

或许此时你也有了计算的思路,通过一点点列等式的方式来计算,下面用代码来写出这个思路

 

function calculatePosition() {
  // 已知条件 中心点位置为 (300, 250) ,宽度为 200, 高度为 100 的矩形, 发生如下变换: 先发生平移(50,0), 然后发生旋转(45deg), 最后发生缩放(2,1)
  const activeCenterPosition = [300, 250] // 中心点位置
  const width = 200 // 宽度
  const height = 100 // 高度
  const transitionDelta = [50, 0]
  const radianDelta = -Math.PI * 0.25 // 瞬时针旋转 45 度转变为弧度变化为 -Math.PI * 0.25
  const scaleDelta = [2, 1]
  const newActiveCenterPosition = [
    activeCenterPosition[0] + transitionDelta[0],
    activeCenterPosition[1] + transitionDelta[1],
  ] // 发生平移
  const newWidth = width * scaleDelta[0] // 缩放后的宽度
  const newHeight = height * scaleDelta[1] // 缩放后的高度
  // 然后我们首先计算 A、B、C、D 四个点相对于 P 点的坐标位置, 接下来要计算 A、B、C、D 四个点与 X 轴正方向的夹角
  const pointARadian = Math.atan2(newHeight * 0.5, -newWidth * 0.5) // 发生旋转之前 A 点与 X 轴的夹角(弧度)
  const pointBRadian = Math.atan2(newHeight * 0.5, newWidth * 0.5) // 发生旋转之前 B 点与 X 轴的夹角(弧度)
  const pointCRadian = Math.atan2(-newHeight * 0.5, newWidth * 0.5) // 发生旋转之前 C 点与 X 轴的夹角(弧度)
  const pointDRadian = Math.atan2(-newHeight * 0.5, -newWidth * 0.5) // 发生旋转之前 D 点与 X 轴的夹角(弧度)
  const newPointARadian = pointARadian + radianDelta // 旋转后的点 A 弧度
  const newPointBRadian = pointBRadian + radianDelta // 旋转后的点 B 弧度
  const newPointCRadian = pointCRadian + radianDelta // 旋转后的点 C 弧度
  const newPointDRadian = pointDRadian + radianDelta // 旋转后的点 D 弧度
  // 利用小学一年级都会勾股定理,计算出各点到中心的距离...
  const distanceA2P = (distanceB2P = distanceC2P = distanceD2P = Math.sqrt(
    Math.pow(newHeight * 0.5, 2) + Math.pow(newWidth * 0.5, 2),
  ))
  // 接下来就可以计算出点 A、B、C、D 相对于中心点 P 的位置了
  const pointARelativeP = [
    distanceA2P * Math.cos(newPointARadian),
    distanceA2P * Math.sin(newPointARadian),
  ]
  const pointBRelativeP = [
    distanceB2P * Math.cos(newPointBRadian),
    distanceB2P * Math.sin(newPointBRadian),
  ]
  const pointCRelativeP = [
    distanceC2P * Math.cos(newPointCRadian),
    distanceC2P * Math.sin(newPointCRadian),
  ]
  const pointDRelativeP = [
    distanceD2P * Math.cos(newPointDRadian),
    distanceD2P * Math.sin(newPointDRadian),
  ]
  // 把 P 点的平移量加上,就得到了 A、B、C、D 相对于页面左上角原点的位置了
  const pointAPosition = [
    pointARelativeP[0] + newActiveCenterPosition[0],
    -pointARelativeP[1] + newActiveCenterPosition[1],
  ]
  const pointBPosition = [
    pointBRelativeP[0] + newActiveCenterPosition[0],
    -pointBRelativeP[1] + newActiveCenterPosition[1],
  ]
  const pointCPosition = [
    pointCRelativeP[0] + newActiveCenterPosition[0],
    -pointCRelativeP[1] + newActiveCenterPosition[1],
  ]
  const pointDPosition = [
    pointDRelativeP[0] + newActiveCenterPosition[0],
    -pointDRelativeP[1] + newActiveCenterPosition[1],
  ]
  return { pointAPosition, pointBPosition, pointCPosition, pointDPosition }
}

 

通过上面的方法已经计算出来各点的坐标了,但是这个过程比较繁琐,虽然不难理解,但是代码给人的感觉仍然是不清晰的,复用起来仍然会有比较高的心智负担.

 

在计算机图形中,涉及到的变换更加复杂,比如在本次的例子中,我们的页面又是相对于一个其他坐标系的子坐标系呢?

 

那幺对于坐标的变换,其实是有更好的方法的,接下来会进入本篇的正题:

 

模型矩阵

 

模型矩阵将局部坐标系下的坐标值转化到世界坐标系下的坐标值所使用的矩阵.为了方便使用角色的坐标,在角色身上,我们往往会以角色中心点为坐标原点建立平面直角坐标系,在这个坐标系下,角色左上点的坐标为{x:-1,y:1},右下点的坐标为{x:1,y:-1}

 

在上面的例子中,红色区域是黄色区域的子坐标系,黄色区域是整个页面的子坐标系,我们可以设红色区域坐标系下 A 点坐标为 (-1, 1), C 点坐标为 (1, -1),而现在我们想要知道 A、B、C、D 四个点在世界坐标系下的位置,所以核心问题就是:点在不同坐标系下的 坐标变换

 

对于局部坐标系上的 x1,y1 ,我们想获取其世界坐标系上的坐标值 x2,y2 ,需要跟着下面的变换思路一步步来:

 

 

    1. 局部坐标系的原点与世界坐标系的原点不在同一个位置,说明坐标系发生了平移,坐标平移变换的方程如下:

 

 

worldX = localX + deltaX
worldY = localY + deltaY

 

根据我们上面讲的将方程转换为矩阵与向量的乘法运算(由于有常数的加入,我们需要将笛卡尔坐标转换为齐次坐标):

 

1,0,deltaX      localX         localX+deltaX//(worldX)
0,1,deltaY  X   localY    =    localY+deltaY//(worldY)
0,0,  1           1                  1

 

 

    1. 局部坐标系的 x 轴正方向与 世界坐标系的 x 轴正方向方向不同, 说明坐标系发生了旋转,坐标旋转变换的方程如下:

 

 

// 设当前的坐标相对于 X 轴正方向的角度为α,顺时针旋转 θ
// 为了看起来更直观,统一用 sin/cos 代替 Math.sin Math.cos
const radius = localX / cos(α) = localY / sin(α)
worldX = radius * cos(α + θ) = radius * (cos(α)cos(θ) - sin(α)sin(θ)) = radius * cos(α)cos(θ) - radius * sin(α)sin(θ) = localX * cos(θ) - localY * sin(θ)
worldY = radius * sin(α + θ) = radius * (sin(α)cos(θ) + cos(α)sin(θ)) = radius * sin(α)cos(θ) + radius * cos(α)sin(θ) = localY * cos(θ) + localX * sin(θ)
worldX = localX * cos(θ) - localY * sin(θ)
worldY = localX * sin(θ) + localY * cos(θ)

 

将方程组转换为矩阵:

 

cos(θ),  -sin(θ),   0             localX          localX * cos(θ) - localY * sin(θ)//(worldX)
sin(θ),  cos(θ),    0     X       localY    =     localX * sin(θ) + localY * cos(θ) //(worldY)
  0   ,    0   ,    1                1                            1

 

 

    1. 局部坐标系和世界坐标系单位长度的距离不同,说明坐标系发生了缩放,坐标缩放变换的方程如下

 

 

worldX = localX * scaleX
worldY = localY * scaleY

 

将方程组转换为矩阵:

 

scaleX,  0   ,  0           localX         localX * scaleX//(worldX)
   0  ,scaleY,  0     X     localY    =    localY * scaleY//(worldY)
   0  ,  0   ,  1              1                  1

 

至此,我们已经拥有了 平移变换矩阵、旋转变换矩阵、缩放变换矩阵,我们只需要依次把矩阵作用向量(坐标)上就可以完成坐标在不同坐标系下的坐标变换了.

 

我们借助 gl-matrix 来进行矩阵的生成和运算,首先制作我们自己的矩阵工具函数:

 

// 在 gl-matrix, 用数组表示矩阵,按照列优先的原则分配矩阵上的值
const matrixUtil = {
    // 生成平移矩阵
    createTranslateMatrix(deltaX, deltaY) {
        return mat3.fromValues(1, 0, 0, 0, 1, 0, deltaX, deltaY, 1)
    },
    // 生成旋转矩阵
    createRotationMatrix(θ) {
        const cos = Math.cos
        const sin = Math.sin
        return mat3.fromValues(cos(θ), sin(θ), 0, -sin(θ), cos(θ), 0, 0, 0, 1)
    },
    // 生成缩放矩阵
    createScaleMatrix(scaleX, scaleY) {
        return mat3.fromValues(scaleX, 0, 0, 0, scaleY, 0, 0, 0, 1)
    },
    // 两矩阵相乘
    multiply(left, right) {
        return mat3.multiply(mat3.create(), left, right)
    },
    // 多矩阵相乘
    multi_multiply(...matrixes) {
        return matrixes.reduce((previousValue, currentValue) => matrixUtil.multiply(previousValue, currentValue))
    }
}

 

首先提一点比较重要的知识,我们都知道矩阵乘法具有结合律,但不具备交换律,从实际的意义来考虑,我们目前进行的计算是矩阵右乘向量,那幺越靠近向量的矩阵,就越先被应用到向量上,比如平移和缩放的先后顺序发生改变,那幺最终结果也会发生变化!

 

那幺我们回到这个小盒子,接下来的重点是注意变换的顺序

 

 

    1. 第一步变换,将红色矩形的点坐标 转换成 黄色矩形坐标系下的坐标

 

 

function transformMatrixStep1() {
    // 为了进行旋转变换,首先将 X 轴 Y 轴的单位长度统一
    const scaleMatrix = matrixUtil.createScaleMatrix(2, 1)
    // X 轴放大 2倍
    const scale2Matrix = matrixUtil.createScaleMatrix(2, 1)
    // 顺时针旋转 0.25 PI
    const rotationMatrix = matrixUtil.createRotationMatrix(Math.PI * -0.25)
    // 在 css 属性上平移 (50,0),对于黄色矩形坐标系实际平移(1,0)
    const translateMatrix = matrixUtil.createTranslateMatrix(1, 0)
    // 将做个变换矩阵合并
    const step1Matrix = matrixUtil.multi_multiply(translateMatrix, rotationMatrix, scale2Matrix, scaleMatrix)
    return step1Matrix
}

 

 

    1. 第二步变换,将 黄色矩形坐标系下的坐标 转换为 页面坐标系下的坐标

 

 

function transformMatrixStep2() {
    // 首先将 黄色矩形坐标系的 x 轴 y 轴单位长度和方向与 页面坐标系统一
    const scaleMatrix = matrixUtil.createScaleMatrix(50, -50)
    // 然后将黄色矩形坐标系的中心点也页面中心点(页面左上角)统一
    const translateMatrix = matrixUtil.createTranslateMatrix(300, 250)
    // 将做个变换矩阵合并
    const step2Matrix = matrixUtil.multi_multiply(translateMatrix, scaleMatrix)
    return step2Matrix
}

 

 

    1. 将两步变换的矩阵合并

 

 

const finalMatrix = matrixUtil.multi_multiply(transformMatrixStep2(), transformMatrixStep1())

 

 

    1. 最后使用矩阵右乘向量就可以得到最终的坐标值了

 

 

const points = [[-1, 1], [1, 1], [1, -1], [-1, -1]]
return points.map(point => vec2.transformMat3(vec2.create(), point, finalMatrix))

 

所有的代码都在这个 html 中,大家可以下载下来调一调、试一试

 

在线展示页面: bfjacky.github.io/dice-toller…

 

git 仓库地址: github.com/BFjacky/dic…

 

在计算机图形中(或者也可以说 WebGL) 中常用的矩阵类型还有投影矩阵、视图矩阵,不过只要是矩阵,其实就是上面的这些内容吧,只是应用在了不同的业务场景上!

Be First to Comment

发表回复

您的电子邮箱地址不会被公开。