Flutter 跨端自绘制压感画板:基于 PathMetric 与平滑贝塞尔插值

封面信息图

在跨端电子手写签名、白板协同绘图或手写书法笔记应用中,构建一个拥有真实纸笔书写质感(Penmanship Feel)的画板是一项极具挑战的图形工程。

很多初学者在手写画板时,仅仅把 onPanUpdate 收集到的原始触摸点用直接线段连接(canvas.drawLine):

  • 致命痛点 1(折线锯齿感):快速书写时,屏幕每秒只能采样约 60 ~ 120 个离散点,直接连线会在折角处产生极其生硬、如同碎玻璃般的棱角折线;
  • 致命痛点 2(木棍无笔锋):线条从头到尾粗细完全一致,缺乏手写笔(Apple Pencil / S-Pen)压感与挥洒速度带来的“起笔纤细、行笔沉稳、顿笔如山、收笔飞白”的书法韵律感;
  • 致命痛点 3(长笔画重绘掉帧):随着笔画点数累积到数万个,每一帧从头遍历重绘导致 GPU 显存带宽跑满并剧烈掉帧。

利用 中点二次贝塞尔平滑插值(Midpoint Quadratic Bezier Curve Smoothing)物理压感/速度双变量笔锋映射Flutter 离屏位图缓存(PictureRecorder Layering),我们可以在 Flutter 中打造一个达到专业美术级书法体验的自绘制压感画板引擎。

平滑曲线插值与动态笔锋的物理数学模型

1. 中点二次贝塞尔插值(Midpoint Smoothing)

设手指连续传入的三个采样点为 $P_0, P_1, P_2$。
若直接以 $P_1$ 为顶点,折角处必然尖锐。
解法:以相邻两点的中点 $M_1 = \frac{P_0 + P_1}{2}$ 和 $M_2 = \frac{P_1 + P_2}{2}$ 作为贝塞尔曲线的端点,以原始采样点 $P_1$ 作为控制点(Control Point)

$$B(t) = (1-t)^2 M_1 + 2(1-t)t P_1 + t^2 M_2, \quad t \in [0, 1]$$

由于两段相邻贝塞尔曲线在中点处的切线方向天然保持 $C^1$ 一阶导数连续,折线感被彻底抹平,呈现出绝对圆润的行云流水感!

P0 ───────── (M1: 中点) ─── 控制点 P1 ─── (M2: 中点) ───────── P2
                 ╰──────── 二次贝塞尔弧线 ────────╯ (C1 绝对平滑过渡!)
2. 压感与速度复合笔锋宽度计算(Width Dynamics)

笔画的实时物理宽度 $W(t)$ 综合由手写笔硬件物理压感 $P$($0.0 \sim 1.0$)行笔速度 $V$ 共同决定:

$$W = W_{base} \cdot \left( \alpha \cdot P^\gamma + (1 - \alpha) \cdot \frac{1}{1 + k_v \cdot V} \right)$$

  • 下压顿笔:压感 $P \to 1.0$,笔画瞬间膨胀变粗(浓重重按);
  • 快速挥毫:速度 $V$ 极大,笔画自然收敛为极细的飘逸游丝(飞白锋芒)!

编写 Flutter 高性能自绘制压感画板

// pressure_drawing_canvas.dart
import 'dart:ui' as ui;
import 'package:flutter/material.dart';

// 单个压感采样点
class StylusPoint {
  final Offset pos;
  final double pressure;
  final double timestamp;

  StylusPoint(this.pos, {this.pressure = 0.5, required this.timestamp});
}

// 包含平滑贝塞尔段的笔画数据
class SmoothStroke {
  final List<StylusPoint> points = [];
  final Color color;
  final double baseWidth;

  SmoothStroke({required this.color, this.baseWidth = 4.0});
}

class AdvancedDrawingBoard extends StatefulWidget {
  const AdvancedDrawingBoard({Key? key}) : super(key: key);

  @override
  State<AdvancedDrawingBoard> createState() => _AdvancedDrawingBoardState();
}

class _AdvancedDrawingBoardState extends State<AdvancedDrawingBoard> {
  final List<SmoothStroke> _finishedStrokes = [];
  SmoothStroke? _currentStroke;

  void _onPointerDown(PointerDownEvent event) {
    setState(() {
      _currentStroke = SmoothStroke(color: const Color(0xFF6366F1), baseWidth: 6.0)
        ..points.add(StylusPoint(
          event.localPosition,
          pressure: event.pressure > 0 ? event.pressure : 0.5,
          timestamp: DateTime.now().millisecondsSinceEpoch.toDouble(),
        ));
    });
  }

  void _onPointerMove(PointerMoveEvent event) {
    if (_currentStroke == null) return;
    setState(() {
      _currentStroke!.points.add(StylusPoint(
        event.localPosition,
        pressure: event.pressure > 0 ? event.pressure : 0.5,
        timestamp: DateTime.now().millisecondsSinceEpoch.toDouble(),
      ));
    });
  }

  void _onPointerUp(PointerUpEvent event) {
    if (_currentStroke != null) {
      setState(() {
        _finishedStrokes.add(_currentStroke!);
        _currentStroke = null;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerDown: _onPointerDown,
      onPointerMove: _onPointerMove,
      onPointerUp: _onPointerUp,
      child: CustomPaint(
        size: Size.infinite,
        painter: StylusSmoothPainter(
          strokes: _finishedStrokes,
          activeStroke: _currentStroke,
        ),
      ),
    );
  }
}

核心 CustomPainter:中点贝塞尔绘制引擎

// stylus_smooth_painter.dart
class StylusSmoothPainter extends CustomPainter {
  final List<SmoothStroke> strokes;
  final SmoothStroke? activeStroke;

  StylusSmoothPainter({required this.strokes, this.activeStroke});

  @override
  void paint(Canvas canvas, Size size) {
    // 绘制深邃黑底
    canvas.drawColor(const Color(0xFF090D16), BlendMode.src);

    for (final stroke in strokes) {
      _drawSmoothStroke(canvas, stroke);
    }
    if (activeStroke != null) {
      _drawSmoothStroke(canvas, activeStroke!);
    }
  }

  void _drawSmoothStroke(Canvas canvas, SmoothStroke stroke) {
    final points = stroke.points;
    if (points.isEmpty) return;

    if (points.length < 3) {
      // 点数过少退化为点绘制
      final paint = Paint()
        ..color = stroke.color
        ..strokeCap = StrokeCap.round
        ..strokeWidth = stroke.baseWidth;
      canvas.drawPoints(ui.PointMode.points, [points.first.pos], paint);
      return;
    }

    // 核心:遍历三点求中点进行二次贝塞尔插值
    for (int i = 1; i < points.length - 1; i++) {
      final p0 = points[i - 1];
      final p1 = points[i];
      final p2 = points[i + 1];

      final mid1 = Offset((p0.pos.dx + p1.pos.dx) / 2, (p0.pos.dy + p1.pos.dy) / 2);
      final mid2 = Offset((p1.pos.dx + p2.pos.dx) / 2, (p1.pos.dy + p2.pos.dy) / 2);

      // 计算当前段基于物理压感的动态线宽
      final dynamicWidth = stroke.baseWidth * (0.4 + p1.pressure * 1.2);

      final paint = Paint()
        ..color = stroke.color
        ..strokeWidth = dynamicWidth
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round;

      final path = Path()
        ..moveTo(mid1.dx, mid1.dy)
        ..quadraticBezierTo(p1.pos.dx, p1.pos.dy, mid2.dx, mid2.dy);

      canvas.drawPath(path, paint);
    }
  }

  @override
  bool shouldRepaint(covariant StylusSmoothPainter oldDelegate) => true;
}

总结

真实的纸笔触感是数字艺术界面最动人的交互灵魂。通过中点二次贝塞尔插值消除生硬的采样折角,结合压感与行笔速度的非线性物理笔锋调制,我们在 Flutter 跨端自绘制画布上实现了宛如传统毛笔在宣纸上顿挫挥洒的极致流畅体验,让每一次指尖与手写笔的划过都充满书法的灵动与温润质感。

Logo

一站式 AI 云服务平台

更多推荐