type
Post
status
Published
date
Mar 29, 2023
slug
summary
tags
category
技术分享
icon
password
最近的目标,给定一条有压感的多点曲线数组,绘制出有手绘感的有粗细变化的线条。
听起来很简单,但实际去做就会发觉,难点超级多。
目前总结下来一共有两个解决方向:
一是类似 Photoshop 中常见笔刷
通过在很小的间隔内连成线的连续绘制笔刷图案,在其中根据压感调整透明度或者密度。
这一条解决方案需要搞定的问题主要是,初始的坐标点组,在输入时可能会因为设备或者移动速度、刷新率、曲率等原因,导致每一条线、不同部分的密度和噪声量截然不同。需要对线条进行平滑和拟合,根据笔刷选项按照一定间距渲染线条。
这个方案因为需要对笔刷图案多次渲染,对性能有一定压力。
二是类似 ill 等矢量工具中的线条
通过算法,将坐标数组扩展成带粗细变化的区域图案。
这一方案同样需要对线条进行平滑和拟合。
并且还需要注意以下几点
- 在大角度转角、坐标点密度过近时会导致边缘坐标出问题;
- 如果想要圆笔刷感需要对起始和结束点补上剩余半球;
- 如果想要尖头笔刷感需要将起始和结束压感修改为零并做适当距离的压感平滑;
- 如果拐角度数大于90度,需要对转弯的外侧线条做补充、内侧线条极端情况下要做精简;
- 起始和结束时的因为下笔和抬笔的原因,需要对特殊的压感数值做处理;
- 重复的范围需要处理掉 或 设置填充方案为忽略内包含
虽然列举了这么注意的点,但是这种渲染线条的方式性能非常好,一次渲染上千条也只需要百毫秒。如果图案生成方案适当,那么输出的线条经过平滑后会边缘锐利、曲线动人。
几天在 v2ex 上翻创造分类,看到一篇 https://www.v2ex.com/t/925273 分享一个网页白板工具,我最近做的东西虽然更多是绘板,但是应该也有不少可以参考的交互和实现,体验了一下看到它的自由线条绘制功能和我的解决方向二是一样的,并且看起来线条很好看、甚至部分细节搞定了我一直在苦恼的事情。翻了一下代码这个项目是拿的 没有在界面上标明😑 真丢人 总之看看它是怎么实现的,Github 的仓库搜索有点难用,直接对着演示页断点
Excalidraw 直接来用,generateElementShape 函数应该是渲染元素之前生成元素的入口函数,element 参数是元素的属性,type === freedraw 是我们想要的自由绘图类型元素,points、pressures 就是我期望的输入值。追到
generateFreeDrawShape 函数export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) { const svgPathData = getFreeDrawSvgPath(element); const path = new Path2D(svgPathData); pathsCache.set(element, path); return path; } export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) { return pathsCache.get(element); } export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { // If input points are empty (should they ever be?) return a dot const inputPoints = element.simulatePressure ? element.points : element.points.length ? element.points.map(([x, y], i) => [x, y, element.pressures[i]]) : [[0, 0, 0.5]]; // Consider changing the options for simulated pressure vs real pressure const options: StrokeOptions = { simulatePressure: element.simulatePressure, size: element.strokeWidth * 4.25, thinning: 0.6, smoothing: 0.5, streamline: 0.5, easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup }; return getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); }
这里面重点在
getStroke 函数上,这个函数名看起来做的就是我说的方案二里面的描边import { getStroke, StrokeOptions } from "perfect-freehand";
找到
perfect-freehand 这个库它处理的线段拟合结果看起来非常妙,节点之间不会非常密集,起始结束封盖也有考虑
它在转弯角度过大时会断成多条线段,这样就不需要考虑转角时外侧需要补一圈点的问题了,但是断开处连贯的会有些不自然。
具体的函数
getStrokeOutlinePoints 看起来和我实现的原理基本一致,只是限制了一个 minDistance 最小点距😢 emmm,这个解决方案只能说比没有强😠再继续翻阅之后,找到了它线看起来好看的原因了。
在输出成
SVG Path 的时,坐标前用了 T 描述符,会让那之后之后的坐标线段自动贝塞尔😡只能说
SVG 真好用,不那么在乎性能的情况托付给 SVG 特性倒是也行。绘谜现在期望大部分画都能在百毫秒内渲染完成,如果在基础的画线上把 SVG 引进来代价太大。还是要想办法在能保证性能的前提下实现。
另外顺便在百度搜索 perfect-freehand 看看有没有国内开发者在这个方向上有没有技术分享,翻到一篇文章。
1. 调研初期 刚开始尝试寻找曲线平滑的算法,主要分两种方案。一是曲线连接方案,主要是一些插值算法。例如要连接A,B两点,一般会根据A,B和前后的点计算一条插值曲线,使得A、B点不会太“生硬”,比如贝塞尔。二是曲线拟合方案,是寻找多个连续的曲线,使得所有离散点“看起来”距离曲线都很近。由于不要求曲线经过所有点,所以可以适应任意密度的坐标点。鼠标坐标点通常都是整数坐标,如果移动比较慢,坐标点会非常紧密。比如想将鼠标指针从(0,0)移动到(1,1),中间可能会经过(0,1)或(1,0),此时曲线连接方案是没办法生成可用曲线的, 这样的抖动需要通过合适的算法消除,曲线拟合方案是比较合适的。这个阶段对鼠标绘制过程没有概念,盲目去搜索各种各样的拟合方案。也尝试通过采样来减少抖动,再利用曲线连接方案,有些效果但没什么价值。
这里面提到的两点在绘谜的第一个版本上也都遇到了,当初为了让线条看起来连贯好看用的是贝塞尔。坐标紧密的问题是靠移动超过一定距离才会记录实现的防抖。
2. 初见突破 一次使用某软件,发现单次绘制结束后,会有一次平滑过程,虽然不是实时的。通过各种方式搜索,找到了 Paper.js 路径简化示例。实现原理大概是:选择起始点和终点,作为拟合范围 对该拟合范围,计算一条Bezier曲线 对该范围内的点,计算点与曲线的误差 如果误差都在期望范围内,结束。 否则,选择误差最大的点,作为分割点,将该拟合范围的离散点分割成两段,分别拟合。 将该方案应用到鼠标绘制,由于新增坐标点会影响分段,对所有坐标拟合的话,每次的曲线都不一样,整条路径都在抖动。考虑过一个方案是,按时间或距离强制插入一个分段点,避免靠前的路径抖动。效果提升明显,但没什么实际用途。至此告一段路,之后很长时间都没再继续研究。
这里提到的 Paper.js 的这个路径简化效果和比绘谜第一版实现的线段简化更适合贝塞尔渲染方案!如果能早些看到就好了。绘谜当初的路径简化方案简化后使用贝塞尔会导致转角细节进一步损失。
- 作者:NotionNext
- 链接:https://tangly1024.com/article/ff8e68ff-bf3e-417e-b9bd-154204424065
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。


