🦀 Rust + WASM 实战系列 第 18 篇 阅读时间:约 8 分钟 | 实战可运行
📌 写在前面
前两篇(16 曼德博、17 Julia)只能滑块调参数------没法"画布式"探索分形。
这一篇升级体验 :鼠标滚轮缩放(围绕光标)+ 拖拽平移 ,像用地图软件一样自由探索分形的细节。
同时首次实测性能优化 :f32 vs f64 真实数据对比。
🚀 TL;DR
| 功能 | 实现 |
|---|---|
| 滚轮缩放 | 围绕鼠标位置缩放(不是中心) |
| 拖拽平移 | 鼠标按下拖动 → 平移复平面中心 |
| 拖动时降分辨率 | 渲染 1/3 分辨率,速度 ×9 |
| f32 vs f64 | 一键切换,对比渲染时间 |
ini
600×600 + iter=200 ≈ 7200 万次浮点运算
↓
f64 WASM:~150 ms
↓
f32 WASM:~75 ms (实测 ~2× 加速)
↓
拖动时降分辨率(200×200):~10 ms (快 15×)
📖 目录
- 滚轮缩放:围绕鼠标位置
- 拖拽平移:鼠标事件处理
- [拖动时降分辨率:性能 vs 视觉的取舍](#拖动时降分辨率:性能 vs 视觉的取舍 "#%E4%B8%89%E6%8B%96%E5%8A%A8%E6%97%B6%E9%99%8D%E5%88%86%E8%BE%A8%E7%8E%87%E6%80%A7%E8%83%BD-vs-%E8%A7%86%E8%A7%89%E7%9A%84%E5%8F%96%E8%88%8D")
- [f32 vs f64:实测对比](#f32 vs f64:实测对比 "#%E5%9B%9Bf32-vs-f64%E5%AE%9E%E6%B5%8B%E5%AF%B9%E6%AF%94")
- 关键代码
- 前端效果展示
- 踩坑提醒
- 第四部分收官
一、滚轮缩放:围绕鼠标位置
关键问题
普通缩放是"以图片中心为锚点"------但用户期望的是"以鼠标位置为锚点"(地图软件、Photoshop 都这么做)。
直觉 :滚轮向上滚(放大)时,鼠标指着的那个点应该保持不动。
数学推导
缩放前:
oldCx=centerX+zoommouseX−w/2
也就是说,鼠标位置的复平面坐标是 oldCx。
缩放后 (zoom *= factor),我们希望同一个 mouseX 仍然对应 oldCx:
oldCx=newCenterX+newZoommouseX−w/2
解出 newCenterX:
newCenterX=oldCx−newZoommouseX−w/2
代码
javascript
function onWheel(e) {
const rect = canvas.getBoundingClientRect()
// 1. 鼠标在 canvas 上的像素坐标(0 ~ 600)
const mx = (e.clientX - rect.left) * (W / rect.width)
const my = (e.clientY - rect.top) * (H / rect.height)
// 2. 算出缩放前鼠标处的复平面坐标
const oldCx = centerX + (mx - W / 2) / zoom
const oldCy = centerY + (my - H / 2) / zoom
// 3. 更新 zoom(向上滚放大)
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2
zoom *= factor
// 4. 调整 center,让鼠标位置保持不变
centerX = oldCx - (mx - W / 2) / zoom
centerY = oldCy - (my - H / 2) / zoom
}
4 行核心代码------完成了"以光标为中心缩放"。
二、拖拽平移:鼠标事件处理
3 个事件
javascript
let isDragging = false
let lastX = 0, lastY = 0
canvas.onmousedown = (e) => {
isDragging = true
lastX = e.clientX
lastY = e.clientY
// 拖动时降分辨率
renderScale = 0.33
}
canvas.onmousemove = (e) => {
if (!isDragging) return
const dxPx = e.clientX - lastX // 像素位移
const dyPx = e.clientY - lastY
// 转成复平面位移
centerX -= dxPx / zoom
centerY -= dyPx / zoom
lastX = e.clientX
lastY = e.clientY
}
canvas.onmouseup = () => {
isDragging = false
// 松开后恢复全分辨率重渲
renderScale = 1.0
render()
}
方向问题
直觉:拖右 → 图片跟着往右 → 视野往左。
数学上:复平面中心往反方向移动:
javascript
centerX -= dxPx / zoom // 注意是 -=
三、拖动时降分辨率:性能 vs 视觉的取舍
问题
600×600 画布每次渲染 7200 万次浮点运算,WASM f64 要 ~150ms。
拖动时每秒触发 30+ 次 mousemove 约等于触发间隔时间33.333ms,但每 150ms 才能完成一次渲染------严重掉帧。
解决方案:降分辨率渲染
拖动时用 1/3 分辨率(200×200),渲染时间降到 ~10ms:
python
600×600 像素:360,000 像素 × ~100 iter ≈ 3600 万次 → 150 ms
200×200 像素: 40,000 像素 × ~100 iter ≈ 400 万次 → 10 ms (快 15×)
视觉效果 :拖动时图稍微模糊,但流畅 。松手后再渲染全分辨率。
代码
javascript
function render() {
const W2 = Math.round(W * renderScale)
const H2 = Math.round(H * renderScale)
// 用 W2, H2 调 WASM
if (renderScale === 1.0) {
// 全分辨率:直接 putImageData
ctx.putImageData(imageData, 0, 0)
} else {
// 低分辨率:先画到临时 canvas,再 drawImage 放大
const tmp = document.createElement('canvas')
tmp.width = W2
tmp.height = H2
tmp.getContext('2d').putImageData(imageData, 0, 0)
ctx.imageSmoothingEnabled = true
ctx.drawImage(tmp, 0, 0, W, H) // 放大到 600×600
}
}
imageSmoothingEnabled = true 让浏览器用双线性插值放大------比"像素块"好看得多。
性能对比
| 场景 | 分辨率 | f64 耗时 | f32 耗时 |
|---|---|---|---|
| 静态(不拖动) | 600×600 | ~150 ms | ~75 ms |
| 拖动中 | 200×200 | ~10 ms | ~5 ms |
| 松手后 | 600×600 | ~150 ms | ~75 ms |
拖动流畅 + 松手清晰------两全其美。
四、f32 vs f64:实测对比
理论分析
| 维度 | f64 | f32 |
|---|---|---|
| 字节数 | 8 | 4 |
| 有效数字 | 15~17 | 6~7 |
| SIMD 吞吐量 | 1× | 2× |
| Mandelbrot 够用? | 绑绑有余 | 绑绑有余 |
为什么 f32 够用? Mandelbrot 迭代范围在 [-2, 2],完全用不到 17 位有效数字------f32 的 7 位绑绑有余。
实测数据(600×600, iter=200)
| 精度 | 平均耗时 | 加速比 |
|---|---|---|
| f64 | ~150 ms | 1× |
| f32 | ~75 ms | ~2× |
结论 :f32 简单换一下类型,零成本加速 2×------这是性价比最高的优化。
实现方式
rust
// f64 版本
let mut zr = 0.0; // 默认 f64
let mut zi = 0.0;
// f32 版本(只是把类型改了)
let mut zr: f32 = 0.0;
let mut zi: f32 = 0.0;
几乎一模一样的代码------Rust 的类型推断让切换非常容易。
为什么 nalgebra / Rust 默认用 f64? f64 是 IEEE 754 标准,所有平台都保证精度一致 ;f32 在某些边缘情况下可能精度不够。但分形渲染不在那些边缘情况里。
五、关键代码
Rust 端:f32 版本
rust
#[wasm_bindgen]
pub fn mandelbrot_f32(
width: u32, height: u32,
center_x: f64, center_y: f64, zoom: f64,
max_iter: u32, palette: u32,
) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let mut pixels = vec![0u8; w * h * 4];
let cx = center_x as f32;
let cy = center_y as f32;
let inv_zoom = (1.0 / zoom) as f32;
for py in 0..h {
for px in 0..w {
let c_re = cx + (px as f32 - w as f32 / 2.0) * inv_zoom;
let c_im = cy + (py as f32 - h as f32 / 2.0) * inv_zoom;
let mut zr: f32 = 0.0;
let mut zi: f32 = 0.0;
let mut iter: u32 = 0;
while iter < max_iter && (zr * zr + zi * zi) < 4.0 {
let new_zr = zr * zr - zi * zi + c_re;
let new_zi = 2.0 * zr * zi + c_im;
zr = new_zr;
zi = new_zi;
iter += 1;
}
let (r, g, b) = color_from_iter(iter, max_iter, palette);
let idx = (py * w + px) * 4;
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
pixels[idx + 3] = 255;
}
}
pixels
}
Vue 端:滚轮缩放 + 降分辨率
javascript
function onWheel(e) {
e.preventDefault()
const rect = canvas.getBoundingClientRect()
const mx = (e.clientX - rect.left) * (W / rect.width)
const my = (e.clientY - rect.top) * (H / rect.height)
// 鼠标位置对应的复平面坐标
const oldCx = centerX + (mx - W / 2) / zoom
const oldCy = centerY + (my - H / 2) / zoom
// 缩放
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2
zoom *= factor
// 调整中心
centerX = oldCx - (mx - W / 2) / zoom
centerY = oldCy - (my - H / 2) / zoom
// 拖动时降分辨率
renderScale = 0.33
render()
// 200ms 后恢复
setTimeout(() => {
if (!isDragging) {
renderScale = 1.0
render()
}
}, 200)
}
六、前端效果展示
打开页面后:
- 默认显示 Mandelbrot 全景,f64 精度
- 滚轮缩放:以鼠标为中心放大/缩小------海马谷可以放大到像素级细节
- 拖拽平移:按住鼠标拖动 → 平移复平面
- Mandelbrot ↔ Julia 切换:切换到 Julia 预设 c 常数
- f64 ↔ f32 切换 :观察渲染耗时变化------立刻看到 ~150ms → ~75ms
- 拖动时观察"降分辨率"标签:拖动期间分辨率降到 200×200,松手恢复 600×600
七、踩坑提醒
1. 滚轮事件要 preventDefault()
javascript
function onWheel(e) {
e.preventDefault() // ← 否则页面会滚动!
// ...
}
不阻止默认行为,整个页面会跟着滚轮上下滚动。
2. CSS 像素 vs canvas 像素要换算
javascript
const rect = canvas.getBoundingClientRect()
const mx = (e.clientX - rect.left) * (W / rect.width) // ← 关键换算
如果 canvas CSS 显示是 400×400 但内部是 600×600,鼠标坐标必须换算到 canvas 内部坐标系。
3. f32 在极端参数下可能"看起来不一样"
makefile
f64: 0.123456789012345
f32: 0.12345679 ← 截断到 7 位
某些边缘 c 值(极深放大时)f32 可能产生 1-2 像素的色差------视觉上几乎察觉不到,但理论上存在。
4. 拖动时频繁重渲染会卡
javascript
// ❌ 每次 mousemove 都渲染
canvas.onmousemove = (e) => {
centerX.value -= dxPx / zoom.value
// watch 触发 render() → 立即开始
}
// ✅ 用 renderScale + watch 自动降分辨率
canvas.onmousemove = (e) => {
centerX.value -= dxPx / zoom.value
// 监听器自动 render(),但因为 renderScale=0.33,每次只要 10ms
}
降分辨率是关键------不降分辨率的话,拖动期间会卡。
5. mouseup / mouseleave 都要处理
javascript
canvas.onmouseup = onMouseUp // 鼠标松开
canvas.onmouseleave = onMouseUp // 鼠标离开 canvas
不监听 mouseleave,鼠标拖到 canvas 外面就"卡住"了。
八、第四部分收官
3 个分形任务全景
| 任务 | 算法 | 交互 | 性能优化 |
|---|---|---|---|
| 16 曼德博 | ✅ | 滑块调参 | 介绍阶梯 |
| 17 Julia | ✅ | 拖 c 滑块实时变 | --- |
| 18 交互画布 | ✅ | 滚轮 + 拖拽 + 双算法切换 | f32 实测 ~2× + 降分辨率 ~15× |
4 个认知升级
| 认知 | 例子 |
|---|---|
| ✅分形 = 复数迭代 + 逃逸时间 | z = z² + c,超简单公式生成无限复杂度 |
| ✅复数 = 工程数学第二语言 | 电学、信号处理、量子力学、控制论都用 |
| ✅迭代算法 vs 确定性算法 | 分形是第一个"算到收敛"的算法 |
| ✅性能优化 = 性价比工程 | f32 一行改类型 = 2× 加速;降分辨率 = 15× |
性能优化回顾
diff
原始:JS → 1500 ms(卡顿)
+WASM f64 → 150 ms(流畅) 10×
+WASM f32 → 75 ms 2× ← 实测
+降分辨率拖动 → 10 ms 7×
+Web Worker → 3 ms(未来) 25×
+WebGPU → 1 ms(未来) 75×
当前实现覆盖了前 3 行------后两行留作"进阶"。
🎁 写在最后
这一篇的真正主题不是分形------是**"前端性能优化"**:
| 技术 | 加速 | 实现难度 | 收益 |
|---|---|---|---|
| 滚轮缩放 + 拖拽 | ∞(从 0 到能用) | 中 | 用户体验 |
| 降分辨率 | 15× | 低 | 流畅交互 |
| f32 | 2× | 极低 | 零成本加速 |
性价比排序:降分辨率 > f32 > 滚轮缩放 > Web Worker > WebGPU
教训 :性能优化先易后难------先做"低成本高回报"的(f32、降分辨率),再考虑复杂方案。
下部分预告(part 5:矩阵应用:从 RGB 到 PCA):RGB 矩阵变换 → PCA 图片压缩 → 线性回归去噪**------线性代数在图像处理中的实战。。
📦 项目地址 :pixel-math-wasm 🦀 Rust + WebAssembly 实战系列
🏷️ 标签 :#Rust #WebAssembly #分形 #交互 #性能优化 #f32 #降分辨率 #Canvas