🦀 Rust + WASM 实战系列 第 14 篇 阅读时间:约 8 分钟 | 实战可运行
📌 写在前面
上一篇用 Matrix2 做了任意角度旋转。Matrix2 够用,但有个限制:没法表示平移。
这一篇引入 齐次坐标 Matrix3(3×3) ------一个能统一表示缩放 / 斜切 / 旋转 / 平移的"超级矩阵"。后面 16 篇(PCA、回归、3D 投影......)都会用到它。
这一篇 = 学一个工具,干两件事:缩放 + 斜切都靠 Matrix3。
🚀 TL;DR
| 操作 | 矩阵 | 新尺寸 |
|---|---|---|
| 缩放 | sx000sy0txty1 | w′=wsx, h′=hsy |
| 斜切 | 1ky0kx10txty1 | w′=w+∥kx∥h |
关键认知 :两件事用的是同一个 affine_apply() 函数 ------只是构造的矩阵不同。这就是 Matrix3 的威力。
📖 目录
- [齐次坐标:为什么需要 Matrix3?](#齐次坐标:为什么需要 Matrix3? "#%E4%B8%80%E9%BD%90%E6%AC%A1%E5%9D%90%E6%A0%87%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-matrix3")
- 缩放:最简单的非平凡变换
- 斜切:把矩形扭成平行四边形
- [关键代码:共享 affine_apply](#关键代码:共享 affine_apply "#%E5%9B%9B%E5%85%B3%E9%94%AE%E4%BB%A3%E7%A0%81%E5%85%B1%E4%BA%AB-affine_apply")
- 前端效果展示
- [为什么 "1 个矩阵" 比 "2 个 if" 更强?](#为什么 "1 个矩阵" 比 "2 个 if" 更强? "#%E5%85%AD%E4%B8%BA%E4%BB%80%E4%B9%88-1-%E4%B8%AA%E7%9F%A9%E9%98%B5-%E6%AF%94-2-%E4%B8%AA-if-%E6%9B%B4%E5%BC%BA")
- 踩坑提醒
- 下篇预告
一、齐次坐标:为什么需要 Matrix3?
2D 变换的三种"形状"
| 变换 | 数学形式 | 需要 2×2 还是 3×3? |
|---|---|---|
| 旋转 | x′=xcos−ysin | 2×2 够(纯线性) |
| 缩放 | x′=sx⋅x | 2×2 够(纯线性) |
| 斜切 | x′=x+kx⋅y | 2×2 够(纯线性) |
| 平移 | x′=x+tx | ❌2×2 不够! |
平移为什么搞不定 2×2?
2×2 矩阵乘法永远是"线性变换"------原点 (0,0) 永远映射到原点。无法表达"加常数"。
解决方案:齐次坐标(Homogeneous Coordinates)
把 2D 点升维成 3D: (x,y)→(x,y,1)。
然后用 3×3 矩阵:
ac0bd0txty1 xy1 = ax+by+txcx+dy+ty1 = x′y′1
第三行的 [0 0 1] 是"占位符",保证 (x,y,1) 永远映射回 (x′,y′,1)。
一张表背齐所有变换
SH= sx000sy0001 = 1ky0kx10001 RT= cosθsinθ0−sinθcosθ0001 = 100010txty1
任何"线性 + 平移"的组合都可以写成一个 Matrix3。
二、缩放:最简单的非平凡变换
缩放矩阵
围绕图片中心缩放,结果画布大小 = sxw×syh:
Mscale= sx000sy02w′−sxw2h′−syh1
平移项 2w′−sxw 是为了让"中心"映射到"中心"。
💡 如果不写平移项(设为 0),缩放会围绕左上角进行------也是合法的,但视觉上"图像会跑到画布外"。
反向映射
对每个 dst 像素 (x′,y′),用 M−1 求出 src:
M−1= 1/sx0001/sy0−2sxw′−sxw−2syh′−syh1
代码
rust
#[wasm_bindgen]
pub fn scale(pixels: &[u8], width: u32, height: u32, sx: f64, sy: f64) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let new_w = ((w as f64) * sx.abs()).round() as usize;
let new_h = ((h as f64) * sy.abs()).round() as usize;
let tx = ((new_w as f64) - sx * (w as f64)) / 2.0;
let ty = ((new_h as f64) - sy * (h as f64)) / 2.0;
let m = Matrix3::new(
sx, 0.0, tx,
0.0, sy, ty,
0.0, 0.0, 1.0,
);
affine_apply(pixels, w, h, new_w, new_h, m)
}
affine_apply() 是 scale 和 shear 共用的实现------下面 §四会讲。
几个常见的缩放场景
scaleX |
scaleY |
效果 |
|---|---|---|
| 2.0 | 2.0 | 整体放大 2 倍 |
| 0.5 | 0.5 | 整体缩小一半 |
| 2.0 | 1.0 | 横向拉长(变成宽屏) |
| 1.0 | 0.5 | 纵向压扁 |
| 0.5 | 2.0 | 横向压缩 + 纵向拉长(类似哈哈镜) |
三、斜切:把矩形扭成平行四边形
斜切的直觉
ini
原图(矩形): 斜切 kx=0.5 后(平行四边形):
┌──┬──┬──┐ ┌──┬──┬──┐
│ │ │ │ │ ╲│ ╲│ ╲│
├──┼──┼──┤ ├──┼──┼──┤
│ │ │ │ → │ ╲│ ╲│ ╲│
├──┼──┼──┤ ├──┼──┼──┤
│ │ │ │ │ ╲│ ╲│ ╲│
└──┴──┴──┘ └──┴──┴──┴──┘
↑
每向下 1 像素,x 往右偏 0.5
斜切矩阵
Mshear= 1ky0kx10txty1
其中:
- kx:水平斜切(每向下 1 像素,x 偏移 kx)
- ky:垂直斜切(每向右 1 像素,y 偏移 ky)
- tx,ty:当斜切系数为负时,需要平移回画布
为什么需要平移?
scss
kx = 0.3 时: kx = -0.3 时:
src(0, 0) → dst(0, 0) src(0, 0) → dst(0, 0)
src(0, h-1) → dst(3*(h-1), h-1) src(0, h-1) → dst(-3*(h-1), h-1) ← 出画布!
需要 tx = 0.3 * (h-1) 平移回来
新画布尺寸
w′h′=w+∣kx∣⋅h=h+∣ky∣⋅w
直觉:水平斜切 kx 会让图像"向右下伸长 ∣kx∣⋅h 像素",所以宽度变 w+∣kx∣⋅h。
代码
rust
#[wasm_bindgen]
pub fn shear(pixels: &[u8], width: u32, height: u32, kx: f64, ky: f64) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let new_w = ((w as f64) + kx.abs() * ((h as f64) - 1.0)).round() as usize;
let new_h = ((h as f64) + ky.abs() * ((w as f64) - 1.0)).round() as usize;
// 负斜切时,平移回画布
let offset_x = if kx < 0.0 { -kx * ((h as f64) - 1.0) } else { 0.0 };
let offset_y = if ky < 0.0 { -ky * ((w as f64) - 1.0) } else { 0.0 };
let m = Matrix3::new(
1.0, kx, offset_x,
ky, 1.0, offset_y,
0.0, 0.0, 1.0,
);
affine_apply(pixels, w, h, new_w, new_h, m)
}
四、关键代码:共享 affine_apply
最大亮点 :scale 和 shear 都调用同一个函数 affine_apply()。Matrix3 让"代码复用"达到了极致。
rust
/// 通用 3×3 仿射变换:反向映射 + 双线性插值 + 自动补白边
pub(super) fn affine_apply(
pixels: &[u8],
w: usize,
h: usize,
new_w: usize,
new_h: usize,
m: Matrix3<f64>,
) -> Vec<u8> {
let m_inv = m.try_inverse()
.expect("affine matrix must be invertible");
let pixel_len = new_w * new_h * 4;
let mut result = vec![0u8; pixel_len + 8];
for y in 0..new_h {
for x in 0..new_w {
// 反向映射:dst → src
let v = m_inv * Vector3::new(x as f64, y as f64, 1.0);
let src_x = v.x;
let src_y = v.y;
let dst_idx = (y * new_w + x) * 4;
// 越界 → 填白色
if src_x < 0.0 || src_x >= (w as f64) - 1.0
|| src_y < 0.0 || src_y >= (h as f64) - 1.0
{
result[dst_idx] = 255;
result[dst_idx + 1] = 255;
result[dst_idx + 2] = 255;
result[dst_idx + 3] = 255;
continue;
}
sample_bilinear(pixels, w, h, src_x, src_y, &mut result, dst_idx);
}
}
// 末尾 8 字节写新尺寸
result[pixel_len..pixel_len + 4]
.copy_from_slice(&(new_w as u32).to_le_bytes());
result[pixel_len + 4..pixel_len + 8]
.copy_from_slice(&(new_h as u32).to_le_bytes());
result
}
scale 和 shear 都只是"喂矩阵"
rust
// scale 喂一个缩放矩阵
scale(pixels, w, h, 2.0, 2.0)
→ 构造 M = [2 0 ?; 0 2 ?; 0 0 1]
→ affine_apply(pixels, w, h, 2w, 2h, M)
// shear 喂一个斜切矩阵
shear(pixels, w, h, 0.5, 0.0)
→ 构造 M = [1 0.5 ?; 0 1 ?; 0 0 1]
→ affine_apply(pixels, w, h, w+0.5h, h, M)
// 如果以后想加"先缩放再斜切"?
// → 喂 M = S × H(矩阵乘法),scale 函数都不用动
这就是"组件化"的胜利 :变换是数据(矩阵),不是控制流(if/else)。要加新变换,只需要构造新矩阵,核心循环一行都不用改。
五、前端效果展示

六、为什么"齐次坐标"
来对比一下从第12节到14的写法:
第十二节:每个变换单独写(90° 旋转就是这样)
rust
// 翻
if direction == "horizontal" { src_x = w - 1 - x }
// 转
let (src_x, src_y) = if direction == "ccw" { (y, w - 1 - x) } else { (new_h - 1 - y, x) }
第十三节:用 Matrix2 但要单独写平移(任意角度旋转)
rust
let r = Matrix2::new(c, -s, s, c);
// 中心化偏移...
let src_x = r_inv[(0, 0)] * (x - nw2) + r_inv[(0, 1)] * (y - nh2) + w2;
let src_y = r_inv[(1, 0)] * (x - nw2) + r_inv[(1, 1)] * (y - nh2) + h2;
问题:Matrix2 没法内置平移,中心化要手算坐标。
本节:Matrix3 一统天下(这一篇)✅
rust
let m = Matrix3::new(sx, 0.0, tx, 0.0, sy, ty, 0.0, 0.0, 1.0);
// 平移已经在矩阵里了!
let src = m.try_inverse().unwrap() * Vector3::new(x as f64, y as f64, 1.0);
优势:
- 平移、缩放、斜切统一用同一个矩阵类型
try_inverse()自动求逆,不用手算- 加新变换只需要构造矩阵,核心循环 0 改动
性能对比
| 维度 | if/else | Matrix2 | Matrix3 |
|---|---|---|---|
| 速度 | 最快 | 快 | 快(SIMD 优化) |
| 可扩展 | ❌ 差 | 一般 | ✅ 极好 |
| 支持平移 | 手动 | 手动 | ✅ 内置 |
| 代码复用 | ❌ 差 | 一般 | ✅ 极好 |
实际上 nalgebra 的 Matrix 运算会被 LLVM 优化到和手写循环一样的机器码,速度上几乎无差。
七、注意问题
1. try_inverse() 可能失败
rust
// 不可逆的矩阵(比如缩放系数 = 0)会让 try_inverse 返回 None
let m_inv = m.try_inverse().expect("matrix not invertible");
何时不可逆 ?当矩阵的行列式 = 0 时(例如 sx=sy=0,或所有元素都为 0)。一般正常使用不会触发。
2. 缩放系数为 0 时 new_w = 0
rust
let new_w = ((w as f64) * sx.abs()).round() as usize;
scaleX = 0 会让 new_w = 0,导致后续 vec![0u8; 0 + 8] 出错。前端 UI 限制滑块范围到 [0.1, 3] 就规避了。
3. 边界判断要用 >= w - 1.0 而不是 >= w
双线性插值要取 (x0, x0 + 1),所以 src_x = w - 1 仍合法(x0 = w-1, x1 = w-1 clamp 一下)。和上一篇 rotate 一样的坑。
4. 斜切系数很大时图像严重变形
rust
let kx: f64 = 2.0; // 每向下 1 像素,x 偏 2 像素
kx = 2 + h = 100 → new_w = w + 200,画布宽度翻倍多。斜切系数最好限制在 [-1, 1]。
八、下篇预告
任务 15:鱼眼广角畸变------模拟鱼眼镜头的球面扭曲。
旋转、缩放、斜切都是线性变换(Matrix3 表达):
x′=ax+by+tx,y′=cx+dy+ty
鱼眼是非线性变换:
rθx′=x2+y2 =arctan(r)=θ⋅rx,y′=θ⋅ry
关键差异:鱼眼没有矩阵形式 ------但仍然用反向映射 + 插值这套打法。
这一篇会把前面的"几何变换三件套"扩展到非线性领域,为下一部分的"分形画板"做铺垫。
🎁 写在最后
这一篇最大的收获不是缩放或斜切,而是:
| 认知 |
|---|
| ✅ 齐次坐标 Matrix3 = 线性变换 + 平移的"瑞士军刀" |
✅ 一个affine_apply 函数 + 不同矩阵 = 不同变换 |
| ✅ "变换是数据(矩阵),不是控制流(if/else)" |
| 任务 | 矩阵 | 中心 | 越界处理 |
|---|---|---|---|
| 12 翻转 | 不用 | N/A | 不会发生 |
| 13 旋转 | Matrix2 | 图片中心 | 填白边 |
| 14 缩放/斜切 | Matrix3 | 图片中心 | 填白边 |
| 15 鱼眼 | 非线性 | 图片中心 | 填白边 |
Matrix3 的优势会在下下篇(任务 18:分形缩放 + 拖拽)爆发------那时你做的不是"画一次",而是"反复用同一个 Matrix3 渲染",性能优势会真正显现。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #图像处理 #几何变换 #缩放 #斜切 #齐次坐标 #Matrix3 #nalgebra