从点到线,从线到画:Canvas 画笔工具的实现艺术

✏️ 从点到线,从线到画:Canvas 画笔工具的实现艺术

💡 画笔工具是地图设计中最基础也最重要的工具之一。本文将带你探索 51mazi 项目中画笔工具的实现思路,从点坐标收集到平滑曲线生成,从 perfect-freehand 算法到实时渲染优化,揭秘如何打造一个流畅自然的画笔体验。

📋 目录

🎯 功能简介

画笔工具是地图设计工具中的"手绘神器",让用户可以像在纸上画画一样自由绘制。在 51mazi 的地图设计工具中,画笔工具不仅仅是一个简单的线条绘制功能,而是一个集成了平滑算法、实时渲染、参数控制等多项技术的完整解决方案。

✏️ 功能特性

  • 平滑绘制: 基于 perfect-freehand 算法,生成自然流畅的画笔效果
  • 实时预览: 绘制过程中实时显示画笔轨迹,体验流畅
  • 参数控制: 支持颜色、大小、透明度等参数自定义
  • 压力模拟: 模拟真实画笔的压力变化,让线条更自然
  • 完整历史记录: 支持撤销/重做,操作可追溯

🖼️ 使用场景

在地图设计中,画笔工具常用于:

  • 🗺️ 绘制地形轮廓(山脉、河流、海岸线等)
  • ✍️ 手绘标记和注释
  • 🎨 自由创作和草图绘制

地图设计工具中的画笔工具 - 流畅自然的绘制体验

🔬 核心算法:perfect-freehand

算法原理

perfect-freehand 是一个专门用于生成平滑手绘线条的算法库,它的核心思想是:

  1. 收集点坐标: 记录鼠标移动过程中的所有点
  2. 平滑处理: 对点序列进行平滑处理,消除抖动
  3. 压力模拟: 根据速度变化模拟压力,让线条有粗细变化
  4. 生成路径: 将处理后的点转换为平滑的路径
  5. 填充绘制: 使用填充而非描边,生成更自然的画笔效果

为什么选择 perfect-freehand?

  • 🎨 自然效果: 生成的线条更接近真实手绘效果
  • 性能优秀: 算法高效,适合实时绘制
  • 🔧 易于集成: API 简洁,易于使用和定制
  • 📦 轻量级: 库体积小,不影响应用性能

算法使用

javascript 复制代码
import { getStroke } from 'perfect-freehand'

// 配置参数
const options = {
  size: strokeWidth,        // 画笔大小
  thinning: 0.6,            // 压力变化强度
  smoothing: 0.5,           // 平滑度
  streamline: 0.5,          // 流线化程度
  easing: (t) => Math.sin((t * Math.PI) / 2),  // 缓动函数
  simulatePressure: true    // 模拟压力
}

// 将点数组转换为路径
const inputPoints = points.map(p => [p.x, p.y])
const stroke = getStroke(inputPoints, options)

// 绘制路径
ctx.beginPath()
ctx.moveTo(stroke[0][0], stroke[0][1])
for (let i = 1; i < stroke.length; i++) {
  ctx.lineTo(stroke[i][0], stroke[i][1])
}
ctx.closePath()
ctx.fill()

参数说明:

  • size: 画笔的基础大小
  • thinning: 控制线条粗细变化,值越大变化越明显
  • smoothing: 控制平滑程度,值越大线条越平滑
  • streamline: 控制流线化程度,让线条更流畅
  • simulatePressure: 是否模拟压力,根据速度变化调整粗细

💡 完整算法实现请查看 : src/renderer/src/composables/map/useRender.js

🎯 技术挑战

挑战 1: 点坐标的收集与存储

在绘制过程中,需要实时收集鼠标移动的点坐标:

javascript 复制代码
function onMouseDown(pos) {
  // 开始新的画笔路径
  elements.currentFreeDrawPath.value = {
    type: 'freedraw',
    points: [{ x: pos.x, y: pos.y }],  // 初始化第一个点
    color: color.value,
    strokeWidth: size.value,
    opacity: opacity.value,
    id: Date.now().toString()
  }
}

function onMouseMove(pos) {
  // 添加点到当前路径
  elements.currentFreeDrawPath.value.points.push({ x: pos.x, y: pos.y })
  // 实时渲染
  renderCanvas(false)
}

关键点:

  • ✅ 使用数组存储点序列,保持顺序
  • ✅ 实时添加点,确保轨迹完整
  • ✅ 每次移动都触发渲染,保证流畅性

挑战 2: 实时渲染的性能

绘制过程中需要频繁渲染,性能优化至关重要:

javascript 复制代码
function onMouseMove(pos) {
  if (elements.currentFreeDrawPath.value) {
    elements.currentFreeDrawPath.value.points.push({ x: pos.x, y: pos.y })
    // 不更新边界,避免频繁计算导致性能问题
    renderCanvas(false)
  }
}

function onMouseUp() {
  // 绘制完成时才更新边界
  renderCanvas(true)
}

优化策略:

  • ⚡ 绘制过程中不更新边界(renderCanvas(false)
  • ⚡ 只在绘制完成时更新边界(renderCanvas(true)
  • ⚡ 减少不必要的计算,提升渲染性能

挑战 3: 路径的平滑处理

原始的点序列可能存在抖动,需要通过算法平滑处理:

javascript 复制代码
const options = {
  smoothing: 0.5,      // 平滑度:值越大越平滑
  streamline: 0.5,     // 流线化:让线条更流畅
  thinning: 0.6        // 压力变化:模拟真实画笔
}

效果对比:

  • 未处理: 线条锯齿明显,不够平滑
  • 处理后: 线条流畅自然,接近手绘效果

挑战 4: 透明度的处理

支持透明度控制,让画笔效果更丰富:

javascript 复制代码
ctx.save()
ctx.globalAlpha = (element.opacity || 100) / 100
ctx.fillStyle = element.color
// 绘制路径
ctx.fill()
ctx.restore()

✨ 实现亮点

1. 模块化的 Composables 架构

画笔工具采用 Composables 架构,实现了高内聚、低耦合:

javascript 复制代码
export function usePencilTool({
  canvasRef,
  elements,
  history,
  renderCanvas,
  color,
  size,
  opacity
}) {
  const drawingActive = ref(false)
  
  function onMouseDown(pos) { /* ... */ }
  function onMouseMove(pos) { /* ... */ }
  function onMouseUp() { /* ... */ }
  
  return {
    drawingActive,
    onMouseDown,
    onMouseMove,
    onMouseUp
  }
}

优势:

  • ✅ 代码组织清晰,易于维护
  • ✅ 功能独立,便于测试
  • ✅ 可复用性强,易于扩展

2. 状态管理

使用响应式状态管理绘制状态:

javascript 复制代码
const drawingActive = ref(false)  // 是否正在绘制
const lastPoint = ref({ x: 0, y: 0 })  // 上一个点

// 在绘制过程中更新状态
function onMouseDown(pos) {
  drawingActive.value = true
  lastPoint.value = { ...pos }
}

3. 历史记录集成

完整的撤销/重做支持:

javascript 复制代码
function onMouseDown(pos) {
  history.value.saveState()  // 开始绘制前保存状态
}

function onMouseUp() {
  if (elements.currentFreeDrawPath.value.points.length > 1) {
    elements.freeDrawElements.value.push({ ...elements.currentFreeDrawPath.value })
    history.value.saveState()  // 绘制完成后保存状态
  }
}

4. 路径数据结构

清晰的数据结构设计:

javascript 复制代码
{
  type: 'freedraw',
  points: [
    { x: 100, y: 200 },
    { x: 105, y: 205 },
    // ... 更多点
  ],
  color: '#222222',
  strokeWidth: 5,
  opacity: 100,
  id: '1234567890'
}

设计考虑:

  • ✅ 使用点数组存储路径,便于序列化
  • ✅ 包含所有必要属性,支持完整恢复
  • ✅ 唯一 ID 便于元素管理

💡 完整实现代码请查看 : src/renderer/src/composables/map/tools/usePencilTool.js

⚡ 性能优化

1. 延迟边界更新

绘制过程中不更新边界,减少计算开销:

javascript 复制代码
// 绘制过程中:不更新边界
renderCanvas(false)

// 绘制完成:更新边界
renderCanvas(true)

优化效果:

  • ⚡ 减少边界计算次数
  • ⚡ 提升绘制流畅度
  • ⚡ 降低 CPU 占用

2. 点数组优化

合理控制点数组大小,避免内存占用过大:

javascript 复制代码
// 只在绘制完成时添加到元素数组
if (elements.currentFreeDrawPath.value.points.length > 1) {
  elements.freeDrawElements.value.push({ ...elements.currentFreeDrawPath.value })
}

3. 渲染优化

使用 Canvas 的高效绘制 API:

javascript 复制代码
// 使用 fill 而非 stroke,效果更自然
ctx.fillStyle = element.color
ctx.fill()

4. 状态清理

及时清理临时状态,避免内存泄漏:

javascript 复制代码
function onMouseUp() {
  // 完成绘制后清理临时路径
  elements.currentFreeDrawPath.value = null
  drawingActive.value = false
}

📊 实现流程图

markdown 复制代码
用户按下鼠标
    ↓
创建新的画笔路径
    ├─ 初始化点数组
    ├─ 设置颜色、大小、透明度
    └─ 保存历史状态
    ↓
鼠标移动
    ├─ 添加点到路径
    └─ 实时渲染(不更新边界)
    ↓
继续移动...
    ↓
用户释放鼠标
    ├─ 检查路径有效性(至少2个点)
    ├─ 添加到元素数组
    ├─ 更新边界
    └─ 保存历史状态
    ↓
完成绘制

📝 总结

画笔工具虽然看似简单,但实现过程中涉及了多个技术领域:

  • 🎨 算法应用: perfect-freehand 算法的集成和使用
  • 📊 状态管理: 响应式状态的管理和同步
  • 性能优化: 实时渲染的性能优化策略
  • 🔄 历史记录: 完整的撤销/重做支持
  • 🏗️ 架构设计: 模块化的 Composables 架构

通过模块化的 Composables 架构,我们将画笔工具封装为独立的 usePencilTool,实现了高内聚、低耦合的代码结构,便于维护和扩展。

🚀 下一步探索

如果你想深入了解:


📚 相关链接

🏷️ 标签

#Canvas #perfect-freehand #画笔工具 #平滑绘制 #实时渲染 #Vue3 #前端开发 #性能优化 #Composables


💡 如果这篇文章对你有帮助,请给个 ⭐️ 支持一下!

💡 想深入了解实现细节?欢迎查看 GitHub 上对应的代码文件,每个模块都有详细的注释说明!

相关推荐
代码猎人2 小时前
什么是margin重叠,如何解决
前端
TeamDev2 小时前
使用 Vue.js 构建 Java 桌面应用
java·前端·vue.js
DongHao2 小时前
跨域问题及解决方案
前端·javascript·面试
持续升级打怪中2 小时前
Vue项目中Axios全面封装实战指南
前端·javascript·vue.js
heyCHEEMS2 小时前
为什么放弃 v-if 选择 v-show?为什么组件越用越卡?
前端
百罹鸟2 小时前
【react 高频面试题—核心原理篇】:useEffect 的依赖项如果是数组或对象(引用类型),会有什么问题?如何解决?
前端·react.js·面试
hibear2 小时前
Smart Ticker - 支持任意字符的高性能文本差异动画滚动组件
前端·vue.js·react.js
脱氧核糖核酸2 小时前
2026了你还只会写点prompt?从AI提示词到可控自动化的演进之路
前端
HabaraAi2 小时前
记一次发现 DataTransfer 的 getData 的有趣问题
前端