矩阵专题1-怎么创建模型矩阵(uModelMatrix)

核心定义

  • 模型矩阵Model Matrix,作用是把模型的顶点从局部坐标系变换到世界坐标系
  • 它回答的是 3 个问题:物体放在哪里朝向哪里有多大
  • 在 WebGL 顶点着色器里,常见写法是:
glsl 复制代码
gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aPosition, 1.0);
  • 这里的 uModelMatrix,就是你要创建的模型矩阵。

先建立正确直觉

  • 模型原始顶点通常是"建模时的坐标",比如一个立方体中心在 (0, 0, 0)
  • 但场景里真正显示时,这个立方体可能要:
    • 移动到 (10, 2, -5)
    • y 轴旋转 45
    • 缩放成原来的 2
  • 这些变化不会直接改顶点数据本身,而是通过一个模型矩阵统一描述。
  • GPU 在处理每个顶点时,用这个矩阵把局部点变成世界空间中的点。

模型矩阵本质上怎么创建

  • 模型矩阵本质上是把多个基础变换矩阵乘起来
  • 最常见的基础变换有 3 个:
    • 平移矩阵 T
    • 旋转矩阵 R
    • 缩放矩阵 S

通常写成:

text 复制代码
M = T * R * S

或者更完整一点:

text 复制代码
M = T * Rz * Ry * Rx * S

这表示:

  • 先缩放
  • 再旋转
  • 最后平移

虽然你写的是 T * R * S,但当它作用到顶点上时,真正执行顺序是从右往左。

为什么先从单位矩阵开始

创建任何模型矩阵,第一步几乎都是从单位矩阵开始:

text 复制代码
I =
| 1 0 0 0 |
| 0 1 0 0 |
| 0 0 1 0 |
| 0 0 0 1 |
  • 它相当于"什么都不做"。
  • 一个物体如果还没平移、没旋转、没缩放,那么它的模型矩阵就是单位矩阵。
  • 所有后续变换,都是在这个基础上叠加出来的。

1. 创建平移矩阵

如果物体要移动到 (tx, ty, tz),平移矩阵写成:

text 复制代码
T =
| 1 0 0 tx |
| 0 1 0 ty |
| 0 0 1 tz |
| 0 0 0  1 |

作用:

  • 不改变物体形状和朝向
  • 只改变物体在世界中的位置

比如移动到 (3, 2, -5)

text 复制代码
T =
| 1 0 0  3 |
| 0 1 0  2 |
| 0 0 1 -5 |
| 0 0 0  1 |

2. 创建缩放矩阵

如果物体沿 x/y/z 方向分别缩放 sx/sy/sz

text 复制代码
S =
| sx  0  0  0 |
|  0 sy  0  0 |
|  0  0 sz  0 |
|  0  0  0  1 |

作用:

  • 改变物体大小
  • sx = sy = sz 是等比缩放
  • 不相等时是非均匀缩放

比如放大 2 倍:

text 复制代码
S =
| 2 0 0 0 |
| 0 2 0 0 |
| 0 0 2 0 |
| 0 0 0 1 |

3. 创建旋转矩阵

在 3D 中,旋转通常按坐标轴分别创建。

x 轴旋转角度 rx

text 复制代码
Rx =
| 1    0        0     0 |
| 0 cos(rx) -sin(rx)  0 |
| 0 sin(rx)  cos(rx)  0 |
| 0    0        0     1 |

y 轴旋转角度 ry

text 复制代码
Ry =
|  cos(ry) 0 sin(ry) 0 |
|    0     1   0     0 |
| -sin(ry) 0 cos(ry) 0 |
|    0     0   0     1 |

z 轴旋转角度 rz

text 复制代码
Rz =
| cos(rz) -sin(rz) 0 0 |
| sin(rz)  cos(rz) 0 0 |
|   0         0    1 0 |
|   0         0    0 1 |

如果物体同时有多个旋转,通常组合成:

text 复制代码
R = Rz * Ry * Rx

或者按你的项目约定来。这里一定要注意:

  • 旋转顺序会影响结果
  • Rx * Ry * RzRz * Ry * Rx 通常不是一个效果

4. 把它们乘起来,得到模型矩阵

假设一个物体:

  • 缩放:(2, 2, 2)
  • y 轴旋转 45
  • 平移到 (5, 0, -8)

那么模型矩阵常写作:

text 复制代码
M = T * Ry * S

顶点变换时:

text 复制代码
worldPosition = M * localPosition

含义是:

  • 顶点先被放大
  • 再围绕自身局部原点旋转
  • 最后整体移动到世界中的目标位置

这就是最典型的模型矩阵创建方式。

最重要的一点:顺序为什么不能乱

矩阵乘法不满足交换律:

text 复制代码
A * B != B * A

所以:

text 复制代码
M = T * R * S

text 复制代码
M = S * R * T

结果完全不同。

举个直觉例子:

  • T * R * S:像"先调整模型姿态和大小,再把它放到世界里"
  • R * T * S:像"先把模型移走,再绕世界原点转",轨迹会变成绕圈

在做模型矩阵时,最常见也最符合直觉的是:

text 复制代码
M = T * R * S

也就是:

  • 先缩放
  • 再旋转
  • 最后平移

手写创建模型矩阵的思路

如果你不用库,逻辑一般是这样:

  1. 先创建单位矩阵 I
  2. 根据位置创建 T
  3. 根据旋转创建 RxRyRz
  4. 根据缩放创建 S
  5. 按顺序相乘得到最终 M

伪代码:

js 复制代码
const modelMatrix = multiply(
  translation(tx, ty, tz),
  multiply(
    rotationY(ry),
    multiply(
      rotationX(rx),
      scaling(sx, sy, sz)
    )
  )
);

如果还有 z 轴旋转,就继续插进去。

一个更完整的 JavaScript 示例

下面用最容易理解的方式说明,不依赖框架:

js 复制代码
function degToRad(deg) {
  return deg * Math.PI / 180;
}

function createIdentityMatrix() {
  return new Float32Array([
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
  ]);
}

function createTranslationMatrix(tx, ty, tz) {
  return new Float32Array([
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    tx, ty, tz, 1,
  ]);
}

function createScaleMatrix(sx, sy, sz) {
  return new Float32Array([
    sx, 0,  0,  0,
    0, sy,  0,  0,
    0,  0, sz,  0,
    0,  0,  0,  1,
  ]);
}

function createRotationXMatrix(rad) {
  const c = Math.cos(rad);
  const s = Math.sin(rad);
  return new Float32Array([
    1, 0, 0, 0,
    0, c, s, 0,
    0, -s, c, 0,
    0, 0, 0, 1,
  ]);
}

function createRotationYMatrix(rad) {
  const c = Math.cos(rad);
  const s = Math.sin(rad);
  return new Float32Array([
    c, 0, -s, 0,
    0, 1, 0, 0,
    s, 0, c, 0,
    0, 0, 0, 1,
  ]);
}

function createRotationZMatrix(rad) {
  const c = Math.cos(rad);
  const s = Math.sin(rad);
  return new Float32Array([
    c, s, 0, 0,
    -s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
  ]);
}

你还需要一个矩阵乘法函数:

js 复制代码
function multiplyMatrix4(a, b) {
  const out = new Float32Array(16);

  for (let col = 0; col < 4; col++) {
    for (let row = 0; row < 4; row++) {
      out[col * 4 + row] =
        a[0 * 4 + row] * b[col * 4 + 0] +
        a[1 * 4 + row] * b[col * 4 + 1] +
        a[2 * 4 + row] * b[col * 4 + 2] +
        a[3 * 4 + row] * b[col * 4 + 3];
    }
  }

  return out;
}

最后创建模型矩阵:

js 复制代码
const tx = 5;
const ty = 0;
const tz = -8;

const rx = degToRad(30);
const ry = degToRad(45);
const rz = degToRad(0);

const sx = 2;
const sy = 2;
const sz = 2;

const T = createTranslationMatrix(tx, ty, tz);
const Rx = createRotationXMatrix(rx);
const Ry = createRotationYMatrix(ry);
const Rz = createRotationZMatrix(rz);
const S = createScaleMatrix(sx, sy, sz);

const R = multiplyMatrix4(Rz, multiplyMatrix4(Ry, Rx));
const modelMatrix = multiplyMatrix4(T, multiplyMatrix4(R, S));

这就是一个完整的模型矩阵构造流程。

然后怎么传给 WebGL

创建好后,传给 shader:

js 复制代码
gl.uniformMatrix4fv(uModelMatrixLocation, false, modelMatrix);

顶点着色器里:

glsl 复制代码
attribute vec3 aPosition;

uniform mat4 uModelMatrix;
uniform mat4 uViewMatrix;
uniform mat4 uProjectionMatrix;

void main() {
    gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aPosition, 1.0);
}

为什么有的人矩阵看起来不一样

你会发现很多教程里矩阵摆放位置不一样,这通常有 3 个原因:

  • 有的人按行向量思维讲
  • 有的人按列向量思维讲
  • WebGL / OpenGL 常见的是列主序存储和列向量乘法约定

所以你经常会看到:

  • 平移量写在最后一列
  • 或者写在数组最后一行位置

这不是一定谁错了,而是数学表示方式内存排列方式不同。

真正重要的是 3 件事保持一致:

  • 你的矩阵构造方式
  • 你的乘法函数
  • 你的 shader 乘法约定

只要这三者统一,结果就对。

推荐的实际工程思路

在真实 WebGL 项目里,通常不会每次都手写 16 个数,而是维护对象的几个基础属性:

  • position
  • rotation
  • scale

然后每一帧重新生成模型矩阵:

js 复制代码
modelMatrix = T * R * S

比如对象类里常见会这样设计:

js 复制代码
class Transform {
  constructor() {
    this.position = [0, 0, 0];
    this.rotation = [0, 0, 0];
    this.scale = [1, 1, 1];
    this.modelMatrix = createIdentityMatrix();
  }

  updateMatrix() {
    const T = createTranslationMatrix(
      this.position[0],
      this.position[1],
      this.position[2]
    );

    const Rx = createRotationXMatrix(this.rotation[0]);
    const Ry = createRotationYMatrix(this.rotation[1]);
    const Rz = createRotationZMatrix(this.rotation[2]);

    const S = createScaleMatrix(
      this.scale[0],
      this.scale[1],
      this.scale[2]
    );

    const R = multiplyMatrix4(Rz, multiplyMatrix4(Ry, Rx));
    this.modelMatrix = multiplyMatrix4(T, multiplyMatrix4(R, S));
  }
}

这样你的渲染对象就有了一个很清晰的变换系统。

父子层级时怎么创建模型矩阵

如果场景里有层级关系,比如:

  • 车身是父节点
  • 车轮是子节点

那车轮的变换分两层:

  • 本地模型矩阵:车轮相对车身的位置
  • 世界模型矩阵:车轮最终在世界中的位置

计算方式通常是:

text 复制代码
childWorldMatrix = parentWorldMatrix * childLocalMatrix

所以更准确地说:

  • 单个物体的模型矩阵,往往是localMatrix
  • 真正送入渲染管线时,经常使用它的worldMatrix

这也是很多引擎里 matrixmatrixWorld 分开的原因。

旋转的一个难点:欧拉角和万向节锁

如果你用的是:

  • rotationX
  • rotationY
  • rotationZ

这种方式,本质是欧拉角旋转。它简单直观,但有两个问题:

  • 旋转顺序敏感
  • 可能出现万向节锁

所以复杂项目里,有时会用:

  • 四元数来生成旋转矩阵

但对"怎么创建模型矩阵"这个问题来说,先掌握 T * R * S 已经足够了。


最常见的错误

  • 错误 1:把顺序写反:结果是物体围着世界原点乱转,或者缩放方向不对
  • 错误 2:角度没有转弧度Math.sinMath.cos 要吃弧度,不是角度
  • 错误 3:矩阵乘法函数与存储约定不一致:看起来矩阵没问题,但结果完全错位
  • 错误 4:模型矩阵没每帧更新:位置变了,但画面不动
  • 错误 5:父子关系里直接把 localMatrix 当 worldMatrix:子物体位置会不对
  • 错误 6:把平移放进 3x3 里:3D 里平移通常要靠 4x4 齐次矩阵

怎么判断自己的模型矩阵对不对

你可以按这个顺序自查:

  • 先只用单位矩阵,确认模型能画出来
  • 再只加缩放,确认大小变化正确
  • 再只加平移,确认位置变化正确
  • 再只加单轴旋转,确认方向变化正确
  • 最后再组合 T * R * S

这样最容易定位问题。


一句话记忆

  • 创建模型矩阵,本质就是把物体的平移旋转缩放写成矩阵,然后按固定顺序相乘。
  • 最常见的工程写法是:
text 复制代码
ModelMatrix = Translation * Rotation * Scale
  • 它把模型从局部空间送到世界空间

你可以这样理解到位

  • S 决定"模型本身有多大"
  • R 决定"模型在自己原点附近怎么转"
  • T 决定"模型整体搬到世界哪里去"

所以创建模型矩阵,就是在回答:

  • 这个物体原地有多大
  • 它在原地转成什么朝向
  • 最后它被放到场景哪个位置
相关推荐
前端开发爱好者6 小时前
支持 110 种文件预览!兼容 Vue、React、Svelte!
前端·javascript·vue.js
大家的林语冰7 小时前
👍 尤大重学 Webpack,Vite 8.1 再进化,打包模式复活!
前端·javascript·vite
张元清7 小时前
React useIsomorphicLayoutEffect:修掉 SSR 下的 useLayoutEffect 警告(2026)
前端·javascript·面试
PBitW8 小时前
直接让GPT每日训练我!!!😕😕😕
前端·javascript·面试
拾年2759 小时前
我用 30 行代码,搞懂了大模型是怎么"读"中文的
javascript·人工智能·llm
竹林8189 小时前
从 ethers.js 到 viem:我在一个 DeFi 看板项目中踩过的所有坑与最终方案
前端·javascript
bonechips9 小时前
Tool Use:从"缸中大脑"到 AI Agent 的技术真相
javascript·agent
秋天的一阵风10 小时前
Vue 3 里被严重低估的 API:InjectionKey
前端·javascript·vue.js
kisshyshy10 小时前
从递归到迭代,一文吃透二叉树的核心知识与 JavaScript 实现
javascript·算法·代码规范